- Python 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| scripts | ||
| src/modify_csv | ||
| tests | ||
| .gitignore | ||
| BREAKING_CHANGES.md | ||
| CONTRIBUTING.md | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| RELEASING.md | ||
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
evalreturns is written with Python's CSV writer.Nonebecomes an empty cell and any other value is written asstr(value). When targets are chained,contentis whatever the previous target returned, so it may not be a string. - Script lifetime: the script runs once, then
evalis called for each cell. Top-level variables keep their values between calls, which is useful for lookup tables or counters. Code underif __name__ == "__main__":does not run. - Errors: if
evalraises an exception, the run stops, nothing is written, and the command exits with status 1. - The name
eval: definingevalhides Python's built-ineval()inside your script. Usebuiltins.evalif 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
emailandnamecells 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
INPUTandOUTPUTand pass--force. - Memory: the whole input file is loaded into memory.
- Exit status:
0on success,1when the input, script or output has a problem (reported asmodify-csv: error: ...), and2for 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).