Metadata-Version: 2.4
Name: modify-csv
Version: 2026.10.9
Summary: Apply a user-supplied Python function to a CSV row, column, or cell.
License-Expression: AGPL-3.0-only
Project-URL: Homepage, https://git.astras.online/martinjose/modify-csv
Project-URL: Source, https://git.astras.online/martinjose/modify-csv
Project-URL: Issues, https://git.astras.online/martinjose/modify-csv/issues
Project-URL: Breaking changes, https://git.astras.online/martinjose/modify-csv/src/branch/main/BREAKING_CHANGES.md
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: cli-components>=2026.10.3
Dynamic: license-file

# modify-csv

Apply a user-provided Python function to every cell in one CSV data row, every
cell in one column, or one cell. The CSV's first record is treated as its header
and is never modified. Row and column indexes are zero-based; columns can also
be addressed by their exact header text.

## Install

Requires Python 3.11 or newer. modify-csv is a command-line tool, so the
simplest way to install it is in its own environment with
[pipx](https://pipx.pypa.io/) or [uv](https://docs.astral.sh/uv/):

```sh
pipx install modify-csv
# or
uv tool install modify-csv
```

To install it into the current Python environment instead:

```sh
python -m pip install modify-csv
```

## Quick start

Given `people.csv`:

```csv
name,email
Ada Lovelace, ADA@EXAMPLE.COM
Grace Hopper,Grace@Example.com
```

Generate a script template:

```sh
modify-csv template normalize.py
```

Edit `normalize.py` so `eval` returns the cleaned-up value:

```python
from typing import Any


def eval(content: Any, row: int, col: int) -> Any:
    return content.strip().lower()
```

Apply it to the `email` column:

```sh
modify-csv apply people.csv clean.csv --script-file normalize.py --column email
```

`clean.csv` now contains:

```csv
name,email
Ada Lovelace,ada@example.com
Grace Hopper,grace@example.com
```

## Script contract

The supplied script must define exactly this callable shape:

```python
from typing import Any


def eval(content: Any, row: int, col: int) -> Any:
    return content
```

`Any` is provided by Python's standard-library `typing` module. The function
name, parameter names, and three-argument shape are enforced.

`content` is the current CSV field (initially a string), while `row` and `col`
are zero-based numeric indexes into the data records and columns. The script is
ordinary trusted Python and executes with the same permissions as the command.

- **Return value:** whatever `eval` returns is written with Python's CSV writer.
  `None` becomes an empty cell and any other value is written as `str(value)`.
  When targets are chained, `content` is whatever the previous target returned,
  so it may not be a string.
- **Script lifetime:** the script runs once, then `eval` is called for each
  cell. Top-level variables keep their values between calls, which is useful
  for lookup tables or counters. Code under `if __name__ == "__main__":` does
  not run.
- **Errors:** if `eval` raises an exception, the run stops, nothing is written,
  and the command exits with status 1.
- **The name `eval`:** defining `eval` hides Python's built-in `eval()` inside
  your script. Use `builtins.eval` if you need the built-in.

## Usage

`modify-csv` has two commands, `apply` and `template`. Each has its own help
page with only the options that belong to it:

```sh
modify-csv --help             # the commands
modify-csv apply --help       # targets, script options and the script contract
modify-csv template --help
```

### Apply a script

Give the input and output files, exactly one of `--script` or
`--script-file`, and at least one target:

```sh
modify-csv apply INPUT OUTPUT (--script CODE | --script-file PATH) TARGET... [--force]
```

| Target | Transforms |
| --- | --- |
| `--row INDEX` | every cell in one data row |
| `--column COLUMN` | every data cell in one column |
| `--cell ROW COLUMN` | one cell |

Rows and columns are numbered from zero; data row 0 is the first record after
the header. A column is an index or the column's exact header text; a value
that is a whole number is read as an index. Existing output files are
protected; pass `--force` to replace one.

Apply a script file to a column selected by header:

```sh
modify-csv apply input.csv output.csv --script-file normalize.py --column email
```

Apply inline code to data row 2:

```sh
modify-csv apply input.csv output.csv \
  --script 'def eval(content, row, col): return content.strip()' \
  --row 2
```

Apply inline code to the cell at data row 0 and column 1:

```sh
modify-csv apply input.csv output.csv \
  --script 'def eval(content, row, col): return int(content) * 2' \
  --cell 0 1
```

### Inline scripts or script files

`--script` takes the whole script as a single argument, which suits one-line
functions. In POSIX shells, wrap it in single quotes so characters such as `$`
and `"` reach Python unchanged. For anything longer, such as imports, helper
functions or state shared between cells, write a file (`modify-csv template`
gives you a starting point) and pass it with `--script-file`.

### Chain targets

Targets can be repeated and mixed. The script runs over them in the order they
are given:

```sh
modify-csv apply input.csv output.csv --script-file normalize.py \
  --column email --column name --row 0 --cell 3 phone
```

- A cell covered by several targets is transformed once per target, and each
  time it receives the result of the previous transformation. In the example
  above, the `email` and `name` cells of data row 0 are transformed twice.
- Every target is checked against the CSV before any cell changes. If one is
  out of range or names a missing header, nothing is written.

### Use from Python

`transform_csv` does the same work as `apply`. Each target is a tuple:
`("row", INDEX, None)`, `("column", COLUMN, None)` or `("cell", ROW, COLUMN)`,
where a column is an index or exact header text. Pass one target or a sequence
of them, and any callable that takes `(content, row, col)`:

```python
from pathlib import Path
from typing import Any

from modify_csv import Target, transform_csv


def normalize(content: Any, row: int, col: int) -> Any:
    return content.strip()


targets: list[Target] = [
    ("column", "email", None),
    ("row", 0, None),
    ("cell", 3, "phone"),
]
transform_csv(Path("input.csv"), Path("output.csv"), targets, normalize)
```

The name and signature checks apply only to scripts loaded by the command line.
Pass `overwrite=True` to replace an existing output file. Problems are raised as
`modify_csv.errors.ModifyCsvError`.

### Generate a script template

```sh
modify-csv template transform.py
```

Use `--force` to replace an existing template.

## Behaviour and limits

- **Encoding:** input is read as UTF-8, with or without a byte-order mark
  (BOM). Output is always UTF-8 *without* a BOM, so a BOM in the input is not
  kept. Some versions of Excel need the BOM to recognise UTF-8.
- **Format:** only comma-delimited files with double-quote quoting are read.
  Other delimiters are not detected; a semicolon-separated file, for example, is
  read as a single column. Output uses Python's CSV defaults, quoting fields only
  where needed and ending lines with `\r\n`, so the output can differ from the
  input byte for byte even where no cell changed.
- **Shape:** every data row must have as many fields as the header. Otherwise
  the command reports the first mismatched row and writes nothing.
- **Columns by name:** header text must match exactly, including case and
  spaces. A header name that appears more than once is reported as ambiguous;
  select that column by index. A header that is a whole number, such as `2024`,
  can only be selected by index, because whole numbers are read as indexes.
- **Output:** the result is written to a temporary file in the output folder
  and then moved into place, so a failed run never leaves a partial file. Missing
  output folders are created. To edit a file in place, give the same path as
  `INPUT` and `OUTPUT` and pass `--force`.
- **Memory:** the whole input file is loaded into memory.
- **Exit status:** `0` on success, `1` when the input, script or output has a
  problem (reported as `modify-csv: error: ...`), and `2` for command-line usage
  errors.

## Breaking changes

Releases that change the command line or Python API in incompatible ways are
listed, with migration steps, in
[BREAKING_CHANGES.md](https://git.astras.online/martinjose/modify-csv/src/branch/main/BREAKING_CHANGES.md).

## Contributing

Development setup, tests and conventions are in
[CONTRIBUTING.md](https://git.astras.online/martinjose/modify-csv/src/branch/main/CONTRIBUTING.md).

## License

Licensed under the
[GNU Affero General Public License v3](https://git.astras.online/martinjose/modify-csv/src/branch/main/LICENSE)
(`AGPL-3.0-only`).
