Simple CLI app allows processing of CSV "cells' through custom Python scripts.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
martinjose 6ce8c2b871 ci: Skip release staging when the version is unchanged (#10)
Stage Release runs on every push to `main` that touches `pyproject.toml`, so metadata or tool-config changes (PR #7, PR #8) failed at "Tag already exists".

- A new `check` job compares `project.version` with the previous commit (`forgejo.event.before`, falling back to `HEAD^`).
- The `stage` job runs only when the version changed. A changed version whose tag already exists still fails.
- Checked against history: the #7 and #8 merges skip; the 2026.10.8 and 2026.10.9 release merges stage.

No version bump: tooling-only change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01XkT5ZcGgGrktq26E71bJdt

Reviewed-on: #10
2026-10-22 01:13:50 +00:00
.forgejo/workflows ci: Skip release staging when the version is unchanged (#10) 2026-10-22 01:13:50 +00:00
scripts feat!: Build the CLI with cli-components and chain apply targets (#8) 2026-10-22 00:41:39 +00:00
src/modify_csv feat!: Build the CLI with cli-components and chain apply targets (#8) 2026-10-22 00:41:39 +00:00
tests feat!: Build the CLI with cli-components and chain apply targets (#8) 2026-10-22 00:41:39 +00:00
.gitignore Add initial implementation of modify-csv tool with CLI support and CSV transformation functionality 2026-10-15 17:26:08 -05:00
BREAKING_CHANGES.md chore: Prepare release 2026.10.9 (#9) 2026-10-22 01:00:08 +00:00
CONTRIBUTING.md feat!: Build the CLI with cli-components and chain apply targets (#8) 2026-10-22 00:41:39 +00:00
LICENSE chore: License under AGPL-3.0-only (#7) 2026-10-20 21:21:22 +00:00
pyproject.toml chore: Prepare release 2026.10.9 (#9) 2026-10-22 01:00:08 +00:00
README.md feat!: Build the CLI with cli-components and chain apply targets (#8) 2026-10-22 00:41:39 +00:00
RELEASING.md ci: Skip release staging when the version is unchanged (#10) 2026-10-22 01:13:50 +00:00

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 or uv:

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

To install it into the current Python environment instead:

python -m pip install modify-csv

Quick start

Given people.csv:

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

Generate a script template:

modify-csv template normalize.py

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

from typing import Any


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

Apply it to the email column:

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

clean.csv now contains:

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

Script contract

The supplied script must define exactly this callable shape:

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:

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:

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:

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

Apply inline code to data row 2:

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:

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:

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):

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

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.

Contributing

Development setup, tests and conventions are in CONTRIBUTING.md.

License

Licensed under the GNU Affero General Public License v3 (AGPL-3.0-only).