modify-csv 2026.10.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modify_csv/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Tools for applying Python transformations to CSV files."""
2
+
3
+ from .transform import CellEvaluator, transform_csv
4
+
5
+ __all__ = ["CellEvaluator", "transform_csv"]
modify_csv/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
modify_csv/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Sequence
7
+
8
+ from .errors import ModifyCsvError
9
+ from .script import load_evaluator, load_evaluator_file
10
+ from .transform import Target, transform_csv
11
+
12
+ EVAL_SIGNATURE = "def eval(content: Any, row: int, col: int) -> Any:"
13
+ TEMPLATE = f'''"""CSV cell transformation."""
14
+
15
+ from typing import Any
16
+
17
+
18
+ {EVAL_SIGNATURE}
19
+ """Return the transformed cell content."""
20
+ return content
21
+ '''
22
+
23
+
24
+ def _parser_description() -> str:
25
+ return f"Required script function:\n {EVAL_SIGNATURE}"
26
+
27
+
28
+ def _column(value: str) -> int | str:
29
+ try:
30
+ index = int(value)
31
+ except ValueError:
32
+ return value
33
+ if index < 0:
34
+ raise argparse.ArgumentTypeError("column index must be zero or greater")
35
+ return index
36
+
37
+
38
+ def _row(value: str) -> int:
39
+ try:
40
+ index = int(value)
41
+ except ValueError as exc:
42
+ raise argparse.ArgumentTypeError("row must be a zero-based integer") from exc
43
+ if index < 0:
44
+ raise argparse.ArgumentTypeError("row index must be zero or greater")
45
+ return index
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="modify-csv",
51
+ description="Apply a Python eval function to one CSV row, column, or cell.",
52
+ epilog=_parser_description(),
53
+ formatter_class=argparse.RawDescriptionHelpFormatter,
54
+ )
55
+ commands = parser.add_subparsers(dest="command", required=True)
56
+ apply_parser = commands.add_parser(
57
+ "apply",
58
+ help="apply a script to a CSV target",
59
+ description="Apply a Python eval function to one CSV row, column, or cell.",
60
+ epilog=_parser_description(),
61
+ formatter_class=argparse.RawDescriptionHelpFormatter,
62
+ )
63
+ apply_parser.add_argument("input", type=Path, help="input CSV file")
64
+ apply_parser.add_argument("output", type=Path, help="output CSV file")
65
+
66
+ scripts = apply_parser.add_mutually_exclusive_group(required=True)
67
+ scripts.add_argument("--script-file", type=Path, help="path to a Python script")
68
+ scripts.add_argument("--script", help="inline Python script text")
69
+
70
+ targets = apply_parser.add_mutually_exclusive_group(required=True)
71
+ targets.add_argument("--row", type=_row, help="zero-based data row index")
72
+ targets.add_argument(
73
+ "--column", type=_column, help="zero-based column index or exact header name"
74
+ )
75
+ targets.add_argument(
76
+ "--cell",
77
+ nargs=2,
78
+ metavar=("ROW", "COLUMN"),
79
+ help="zero-based data row and a column index or header name",
80
+ )
81
+ apply_parser.add_argument(
82
+ "--force", action="store_true", help="overwrite the output file if it exists"
83
+ )
84
+
85
+ template_parser = commands.add_parser(
86
+ "template",
87
+ help="generate a script template",
88
+ description="Generate a Python script implementing the required contract.",
89
+ epilog=_parser_description(),
90
+ formatter_class=argparse.RawDescriptionHelpFormatter,
91
+ )
92
+ template_parser.add_argument("output", type=Path, help="template output path")
93
+ template_parser.add_argument(
94
+ "--force", action="store_true", help="overwrite the output file if it exists"
95
+ )
96
+ return parser
97
+
98
+
99
+ def _target_from_args(args: argparse.Namespace, parser: argparse.ArgumentParser) -> Target:
100
+ if args.row is not None:
101
+ return ("row", args.row, None)
102
+ if args.column is not None:
103
+ return ("column", args.column, None)
104
+
105
+ row_text, column_text = args.cell
106
+ try:
107
+ row = _row(row_text)
108
+ column = _column(column_text)
109
+ except argparse.ArgumentTypeError as exc:
110
+ parser.error(str(exc))
111
+ return ("cell", row, column)
112
+
113
+
114
+ def _write_template(output: Path, overwrite: bool) -> None:
115
+ if output.exists() and not overwrite:
116
+ raise ModifyCsvError(
117
+ f"output file already exists: {output} (use --force to overwrite)"
118
+ )
119
+ try:
120
+ output.parent.mkdir(parents=True, exist_ok=True)
121
+ output.write_text(TEMPLATE, encoding="utf-8")
122
+ except OSError as exc:
123
+ raise ModifyCsvError(f"could not write template {output}: {exc}") from exc
124
+
125
+
126
+ def main(argv: Sequence[str] | None = None) -> int:
127
+ parser = build_parser()
128
+ arguments = list(sys.argv[1:] if argv is None else argv)
129
+ # Preserve the original invocation while steering new usage toward subcommands.
130
+ if arguments and arguments[0] not in {"apply", "template", "-h", "--help"}:
131
+ arguments.insert(0, "apply")
132
+ args = parser.parse_args(arguments)
133
+
134
+ try:
135
+ if args.command == "template":
136
+ _write_template(args.output, args.force)
137
+ return 0
138
+
139
+ target = _target_from_args(args, parser)
140
+ evaluator = (
141
+ load_evaluator_file(args.script_file)
142
+ if args.script_file is not None
143
+ else load_evaluator(args.script)
144
+ )
145
+ transform_csv(
146
+ args.input,
147
+ args.output,
148
+ target,
149
+ evaluator,
150
+ overwrite=args.force,
151
+ )
152
+ except ModifyCsvError as exc:
153
+ print(f"modify-csv: error: {exc}", file=sys.stderr)
154
+ return 1
155
+ return 0
modify_csv/errors.py ADDED
@@ -0,0 +1,6 @@
1
+ class ModifyCsvError(Exception):
2
+ """Base class for expected modify-csv errors."""
3
+
4
+
5
+ class ScriptContractError(ModifyCsvError):
6
+ """Raised when a supplied script does not implement the required contract."""
modify_csv/script.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from pathlib import Path
5
+ from typing import Any, Callable
6
+
7
+ from .errors import ScriptContractError
8
+
9
+ CellEvaluator = Callable[[Any, int, int], Any]
10
+
11
+
12
+ def load_evaluator(source: str, source_name: str = "<inline script>") -> CellEvaluator:
13
+ """Execute source and return its contract-compliant ``eval`` function."""
14
+ namespace: dict[str, Any] = {"__name__": "__modify_csv_script__"}
15
+ try:
16
+ code = compile(source, source_name, "exec")
17
+ exec(code, namespace)
18
+ except Exception as exc:
19
+ raise ScriptContractError(f"could not load {source_name}: {exc}") from exc
20
+
21
+ evaluator = namespace.get("eval")
22
+ if not callable(evaluator):
23
+ raise ScriptContractError(
24
+ "script must define a callable named eval with signature "
25
+ "eval(content: Any, row: int, col: int) -> Any"
26
+ )
27
+
28
+ signature = inspect.signature(evaluator)
29
+ parameters = list(signature.parameters.values())
30
+ expected_names = ["content", "row", "col"]
31
+ valid_kinds = {
32
+ inspect.Parameter.POSITIONAL_ONLY,
33
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
34
+ }
35
+ if (
36
+ len(parameters) != 3
37
+ or [parameter.name for parameter in parameters] != expected_names
38
+ or any(parameter.kind not in valid_kinds for parameter in parameters)
39
+ or any(parameter.default is not inspect.Parameter.empty for parameter in parameters)
40
+ ):
41
+ raise ScriptContractError(
42
+ f"eval has signature {signature}; expected "
43
+ "(content: Any, row: int, col: int) -> Any"
44
+ )
45
+
46
+ return evaluator
47
+
48
+
49
+ def load_evaluator_file(path: Path) -> CellEvaluator:
50
+ try:
51
+ source = path.read_text(encoding="utf-8")
52
+ except OSError as exc:
53
+ raise ScriptContractError(f"could not read script {path}: {exc}") from exc
54
+ return load_evaluator(source, str(path))
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any, Callable, TypeAlias
8
+
9
+ from .errors import ModifyCsvError
10
+
11
+ CellEvaluator: TypeAlias = Callable[[Any, int, int], Any]
12
+ Target: TypeAlias = tuple[str, int | str, int | str | None]
13
+
14
+
15
+ def _resolve_column(column: int | str, header: list[str]) -> int:
16
+ if isinstance(column, int):
17
+ index = column
18
+ else:
19
+ matches = [index for index, value in enumerate(header) if value == column]
20
+ if not matches:
21
+ raise ModifyCsvError(f"column header not found: {column!r}")
22
+ if len(matches) > 1:
23
+ raise ModifyCsvError(f"column header is ambiguous: {column!r}")
24
+ index = matches[0]
25
+
26
+ if not 0 <= index < len(header):
27
+ raise ModifyCsvError(
28
+ f"column index {index} is out of range for {len(header)} columns"
29
+ )
30
+ return index
31
+
32
+
33
+ def _apply_target(
34
+ rows: list[list[str]],
35
+ header: list[str],
36
+ target: Target,
37
+ evaluator: CellEvaluator,
38
+ ) -> None:
39
+ kind, first, second = target
40
+
41
+ if kind == "row":
42
+ row_index = int(first)
43
+ if not 0 <= row_index < len(rows):
44
+ raise ModifyCsvError(
45
+ f"row index {row_index} is out of range for {len(rows)} data rows"
46
+ )
47
+ for col_index, content in enumerate(rows[row_index]):
48
+ rows[row_index][col_index] = evaluator(content, row_index, col_index)
49
+ return
50
+
51
+ if kind == "column":
52
+ col_index = _resolve_column(first, header)
53
+ for row_index, row in enumerate(rows):
54
+ row[col_index] = evaluator(row[col_index], row_index, col_index)
55
+ return
56
+
57
+ row_index = int(first)
58
+ if not 0 <= row_index < len(rows):
59
+ raise ModifyCsvError(
60
+ f"row index {row_index} is out of range for {len(rows)} data rows"
61
+ )
62
+ col_index = _resolve_column(second if second is not None else -1, header)
63
+ rows[row_index][col_index] = evaluator(
64
+ rows[row_index][col_index], row_index, col_index
65
+ )
66
+
67
+
68
+ def transform_csv(
69
+ input_path: Path,
70
+ output_path: Path,
71
+ target: Target,
72
+ evaluator: CellEvaluator,
73
+ *,
74
+ overwrite: bool = False,
75
+ ) -> None:
76
+ """Transform the selected CSV cells and atomically write the result."""
77
+ if output_path.exists() and not overwrite:
78
+ raise ModifyCsvError(
79
+ f"output file already exists: {output_path} (use --force to overwrite)"
80
+ )
81
+
82
+ try:
83
+ with input_path.open("r", encoding="utf-8-sig", newline="") as stream:
84
+ records = list(csv.reader(stream))
85
+ except (OSError, csv.Error) as exc:
86
+ raise ModifyCsvError(f"could not read CSV {input_path}: {exc}") from exc
87
+
88
+ if not records:
89
+ raise ModifyCsvError("input CSV is empty and has no header")
90
+
91
+ header, rows = records[0], records[1:]
92
+ if not header:
93
+ raise ModifyCsvError("input CSV has an empty header")
94
+ malformed = next(
95
+ (index for index, row in enumerate(rows) if len(row) != len(header)), None
96
+ )
97
+ if malformed is not None:
98
+ raise ModifyCsvError(
99
+ f"data row {malformed} has {len(rows[malformed])} columns; "
100
+ f"expected {len(header)}"
101
+ )
102
+
103
+ try:
104
+ _apply_target(rows, header, target, evaluator)
105
+ except ModifyCsvError:
106
+ raise
107
+ except Exception as exc:
108
+ raise ModifyCsvError(f"eval failed: {exc}") from exc
109
+
110
+ output_path.parent.mkdir(parents=True, exist_ok=True)
111
+ temporary_name: str | None = None
112
+ try:
113
+ with tempfile.NamedTemporaryFile(
114
+ "w",
115
+ encoding="utf-8",
116
+ newline="",
117
+ dir=output_path.parent,
118
+ prefix=f".{output_path.name}.",
119
+ suffix=".tmp",
120
+ delete=False,
121
+ ) as stream:
122
+ temporary_name = stream.name
123
+ writer = csv.writer(stream)
124
+ writer.writerow(header)
125
+ writer.writerows(rows)
126
+ os.replace(temporary_name, output_path)
127
+ except (OSError, csv.Error) as exc:
128
+ if temporary_name is not None:
129
+ Path(temporary_name).unlink(missing_ok=True)
130
+ raise ModifyCsvError(f"could not write CSV {output_path}: {exc}") from exc
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: modify-csv
3
+ Version: 2026.10.4
4
+ Summary: Apply a user-supplied Python function to a CSV row, column, or cell.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ # modify-csv
9
+
10
+ Apply a user-provided Python function to every cell in one CSV data row, every
11
+ cell in one column, or one cell. The CSV's first record is treated as its header
12
+ and is never modified. Row and column indexes are zero-based; columns can also
13
+ be addressed by their exact header text.
14
+
15
+ ## Install
16
+
17
+ Install a published release from PyPI:
18
+
19
+ ```sh
20
+ python -m pip install modify-csv
21
+ ```
22
+
23
+ Or install a tagged version directly from the private GitHub repository using
24
+ SSH authentication:
25
+
26
+ ```sh
27
+ python -m pip install "git+ssh://git@github.com/martinjose/modify-csv.git@v0.1.0"
28
+ ```
29
+
30
+ Git and access to the private repository are required for the second form. Each
31
+ GitHub Release also provides a universal wheel and source archive.
32
+
33
+ For local development, install an editable copy:
34
+
35
+ ```sh
36
+ python -m pip install -e .
37
+ ```
38
+
39
+ This installs the `modify-csv` command. You can also run the package without an
40
+ installation using `PYTHONPATH=src python -m modify_csv`.
41
+
42
+ ## Script contract
43
+
44
+ The supplied script must define exactly this callable shape:
45
+
46
+ ```python
47
+ from typing import Any
48
+
49
+
50
+ def eval(content: Any, row: int, col: int) -> Any:
51
+ return content
52
+ ```
53
+
54
+ `Any` is provided by Python's standard-library `typing` module. The function
55
+ name, parameter names, and three-argument shape are enforced.
56
+
57
+ `content` is the current CSV field (initially a string), while `row` and `col`
58
+ are zero-based numeric indexes into the data records and columns. The returned
59
+ value is passed to Python's CSV writer. The script is ordinary trusted Python
60
+ and executes with the same permissions as the command.
61
+
62
+ ## Examples
63
+
64
+ Apply a script file to a column selected by header:
65
+
66
+ ```sh
67
+ modify-csv apply input.csv output.csv --script-file normalize.py --column email
68
+ ```
69
+
70
+ Apply inline code to data row 2:
71
+
72
+ ```sh
73
+ modify-csv apply input.csv output.csv \
74
+ --script 'def eval(content, row, col): return content.strip()' \
75
+ --row 2
76
+ ```
77
+
78
+ Apply inline code to the cell at data row 0 and column 1:
79
+
80
+ ```sh
81
+ modify-csv apply input.csv output.csv \
82
+ --script 'def eval(content, row, col): return int(content) * 2' \
83
+ --cell 0 1
84
+ ```
85
+
86
+ Exactly one of `--script` or `--script-file`, and exactly one of `--row`,
87
+ `--column`, or `--cell`, is required. Existing output files are protected;
88
+ pass `--force` to replace one.
89
+
90
+ Generate a ready-to-edit transformation script:
91
+
92
+ ```sh
93
+ modify-csv template transform.py
94
+ ```
95
+
96
+ Use `--force` to replace an existing template. Run `modify-csv --help` or the
97
+ help for either subcommand to see the required function signature. The original
98
+ form without the `apply` subcommand remains supported for compatibility.
99
+
100
+ ## Releasing
101
+
102
+ Versions follow `YYYY.M.RELEASE`, where `RELEASE` starts at `1` each month. For
103
+ example, the first release in October 2026 is `2026.10.1`.
104
+
105
+ Preview the next version without changing the repository:
106
+
107
+ ```sh
108
+ python scripts/release.py next
109
+ ```
110
+
111
+ To update `pyproject.toml`, create the release commit, and create an annotated
112
+ tag locally for inspection:
113
+
114
+ ```sh
115
+ python scripts/release.py prepare
116
+ ```
117
+
118
+ The command prints the exact push command to use after reviewing the commit and
119
+ tag. To prepare and push both atomically in one step instead, run:
120
+
121
+ ```sh
122
+ python scripts/release.py release
123
+ ```
124
+
125
+ Release preparation requires a clean `main` branch. The package version in
126
+ `pyproject.toml` remains the release source of truth. Pushing the generated tag
127
+ starts the GitHub Actions release process:
128
+
129
+ ```sh
130
+ git push --atomic origin main v2026.10.1
131
+ ```
132
+
133
+ The tag-triggered release workflow tests Python 3.10 and 3.13 on Linux, macOS,
134
+ and Windows. It then builds and validates a platform-independent wheel and
135
+ source archive, publishes them to TestPyPI through trusted publishing, verifies
136
+ that the package installs and runs from TestPyPI, and stages the exact artifacts
137
+ in a draft GitHub Release.
138
+
139
+ After reviewing the successful run, open **Actions**, select **Release**, choose
140
+ **Run workflow**, and enter the tag. This explicit manual action verifies and
141
+ publishes the staged artifacts to PyPI, then publishes the draft GitHub Release.
142
+ A mismatched version and tag, missing draft release, or changed artifact causes
143
+ the production run to fail.
144
+
145
+ Trusted publishers must be configured independently on TestPyPI and PyPI. Both
146
+ use owner `martinjose`, repository `modify-csv`, and workflow `release.yml`.
147
+ The TestPyPI publisher uses environment `testpypi`; the production publisher
148
+ uses environment `pypi`. The manually dispatched production run serves as the
149
+ approval gate for private repositories without environment reviewer support.
@@ -0,0 +1,11 @@
1
+ modify_csv/__init__.py,sha256=6vHqQECPhZA56nXpai3qh33V_Bfnyu8Le5j6Ey6C9sw,161
2
+ modify_csv/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ modify_csv/cli.py,sha256=YT1zD8r71uwYJVn4zPdGKh5t1Ut8tKVlIfolLMQcZ0o,5171
4
+ modify_csv/errors.py,sha256=cUY7TeD5Xqgn8TzL-DDy1pBwgA7HGc8joD6AjCiHIZc,213
5
+ modify_csv/script.py,sha256=JtrZO2knEkeOMIMuYmwuFgrTRiEUtUBkURynic007Kk,1890
6
+ modify_csv/transform.py,sha256=BI-tJWyaelutF6pvUcwarFuYC0ILQxydmut6NdMDE9M,4216
7
+ modify_csv-2026.10.4.dist-info/METADATA,sha256=KNwhX1K0_t2W8URWE2euIRHGHpO3dw_4Sng7zRrAALA,4731
8
+ modify_csv-2026.10.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ modify_csv-2026.10.4.dist-info/entry_points.txt,sha256=3tNZwsCtDepnc9plRykrjXruU1rY_mPBpNdWPFwVfuQ,51
10
+ modify_csv-2026.10.4.dist-info/top_level.txt,sha256=8wrCNZWfgQaxZh5pvc291H5DjJEJIv0Cu2R7ms0M3sM,11
11
+ modify_csv-2026.10.4.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ modify-csv = modify_csv.cli:main
@@ -0,0 +1 @@
1
+ modify_csv