modify-csv 2026.10.4__tar.gz
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-2026.10.4/PKG-INFO +149 -0
- modify_csv-2026.10.4/README.md +142 -0
- modify_csv-2026.10.4/pyproject.toml +16 -0
- modify_csv-2026.10.4/setup.cfg +4 -0
- modify_csv-2026.10.4/src/modify_csv/__init__.py +5 -0
- modify_csv-2026.10.4/src/modify_csv/__main__.py +3 -0
- modify_csv-2026.10.4/src/modify_csv/cli.py +155 -0
- modify_csv-2026.10.4/src/modify_csv/errors.py +6 -0
- modify_csv-2026.10.4/src/modify_csv/script.py +54 -0
- modify_csv-2026.10.4/src/modify_csv/transform.py +130 -0
- modify_csv-2026.10.4/src/modify_csv.egg-info/PKG-INFO +149 -0
- modify_csv-2026.10.4/src/modify_csv.egg-info/SOURCES.txt +15 -0
- modify_csv-2026.10.4/src/modify_csv.egg-info/dependency_links.txt +1 -0
- modify_csv-2026.10.4/src/modify_csv.egg-info/entry_points.txt +2 -0
- modify_csv-2026.10.4/src/modify_csv.egg-info/top_level.txt +1 -0
- modify_csv-2026.10.4/tests/test_modify_csv.py +159 -0
- modify_csv-2026.10.4/tests/test_release.py +35 -0
|
@@ -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,142 @@
|
|
|
1
|
+
# modify-csv
|
|
2
|
+
|
|
3
|
+
Apply a user-provided Python function to every cell in one CSV data row, every
|
|
4
|
+
cell in one column, or one cell. The CSV's first record is treated as its header
|
|
5
|
+
and is never modified. Row and column indexes are zero-based; columns can also
|
|
6
|
+
be addressed by their exact header text.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Install a published release from PyPI:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
python -m pip install modify-csv
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or install a tagged version directly from the private GitHub repository using
|
|
17
|
+
SSH authentication:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
python -m pip install "git+ssh://git@github.com/martinjose/modify-csv.git@v0.1.0"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Git and access to the private repository are required for the second form. Each
|
|
24
|
+
GitHub Release also provides a universal wheel and source archive.
|
|
25
|
+
|
|
26
|
+
For local development, install an editable copy:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
python -m pip install -e .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This installs the `modify-csv` command. You can also run the package without an
|
|
33
|
+
installation using `PYTHONPATH=src python -m modify_csv`.
|
|
34
|
+
|
|
35
|
+
## Script contract
|
|
36
|
+
|
|
37
|
+
The supplied script must define exactly this callable shape:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def eval(content: Any, row: int, col: int) -> Any:
|
|
44
|
+
return content
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`Any` is provided by Python's standard-library `typing` module. The function
|
|
48
|
+
name, parameter names, and three-argument shape are enforced.
|
|
49
|
+
|
|
50
|
+
`content` is the current CSV field (initially a string), while `row` and `col`
|
|
51
|
+
are zero-based numeric indexes into the data records and columns. The returned
|
|
52
|
+
value is passed to Python's CSV writer. The script is ordinary trusted Python
|
|
53
|
+
and executes with the same permissions as the command.
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
Apply a script file to a column selected by header:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
modify-csv apply input.csv output.csv --script-file normalize.py --column email
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Apply inline code to data row 2:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
modify-csv apply input.csv output.csv \
|
|
67
|
+
--script 'def eval(content, row, col): return content.strip()' \
|
|
68
|
+
--row 2
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Apply inline code to the cell at data row 0 and column 1:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
modify-csv apply input.csv output.csv \
|
|
75
|
+
--script 'def eval(content, row, col): return int(content) * 2' \
|
|
76
|
+
--cell 0 1
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Exactly one of `--script` or `--script-file`, and exactly one of `--row`,
|
|
80
|
+
`--column`, or `--cell`, is required. Existing output files are protected;
|
|
81
|
+
pass `--force` to replace one.
|
|
82
|
+
|
|
83
|
+
Generate a ready-to-edit transformation script:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
modify-csv template transform.py
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Use `--force` to replace an existing template. Run `modify-csv --help` or the
|
|
90
|
+
help for either subcommand to see the required function signature. The original
|
|
91
|
+
form without the `apply` subcommand remains supported for compatibility.
|
|
92
|
+
|
|
93
|
+
## Releasing
|
|
94
|
+
|
|
95
|
+
Versions follow `YYYY.M.RELEASE`, where `RELEASE` starts at `1` each month. For
|
|
96
|
+
example, the first release in October 2026 is `2026.10.1`.
|
|
97
|
+
|
|
98
|
+
Preview the next version without changing the repository:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
python scripts/release.py next
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
To update `pyproject.toml`, create the release commit, and create an annotated
|
|
105
|
+
tag locally for inspection:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
python scripts/release.py prepare
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The command prints the exact push command to use after reviewing the commit and
|
|
112
|
+
tag. To prepare and push both atomically in one step instead, run:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
python scripts/release.py release
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Release preparation requires a clean `main` branch. The package version in
|
|
119
|
+
`pyproject.toml` remains the release source of truth. Pushing the generated tag
|
|
120
|
+
starts the GitHub Actions release process:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
git push --atomic origin main v2026.10.1
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The tag-triggered release workflow tests Python 3.10 and 3.13 on Linux, macOS,
|
|
127
|
+
and Windows. It then builds and validates a platform-independent wheel and
|
|
128
|
+
source archive, publishes them to TestPyPI through trusted publishing, verifies
|
|
129
|
+
that the package installs and runs from TestPyPI, and stages the exact artifacts
|
|
130
|
+
in a draft GitHub Release.
|
|
131
|
+
|
|
132
|
+
After reviewing the successful run, open **Actions**, select **Release**, choose
|
|
133
|
+
**Run workflow**, and enter the tag. This explicit manual action verifies and
|
|
134
|
+
publishes the staged artifacts to PyPI, then publishes the draft GitHub Release.
|
|
135
|
+
A mismatched version and tag, missing draft release, or changed artifact causes
|
|
136
|
+
the production run to fail.
|
|
137
|
+
|
|
138
|
+
Trusted publishers must be configured independently on TestPyPI and PyPI. Both
|
|
139
|
+
use owner `martinjose`, repository `modify-csv`, and workflow `release.yml`.
|
|
140
|
+
The TestPyPI publisher uses environment `testpypi`; the production publisher
|
|
141
|
+
uses environment `pypi`. The manually dispatched production run serves as the
|
|
142
|
+
approval gate for private repositories without environment reviewer support.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "modify-csv"
|
|
7
|
+
version = "2026.10.4"
|
|
8
|
+
description = "Apply a user-supplied Python function to a CSV row, column, or cell."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
|
|
12
|
+
[project.scripts]
|
|
13
|
+
modify-csv = "modify_csv.cli:main"
|
|
14
|
+
|
|
15
|
+
[tool.setuptools.packages.find]
|
|
16
|
+
where = ["src"]
|
|
@@ -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
|
|
@@ -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,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/modify_csv/__init__.py
|
|
4
|
+
src/modify_csv/__main__.py
|
|
5
|
+
src/modify_csv/cli.py
|
|
6
|
+
src/modify_csv/errors.py
|
|
7
|
+
src/modify_csv/script.py
|
|
8
|
+
src/modify_csv/transform.py
|
|
9
|
+
src/modify_csv.egg-info/PKG-INFO
|
|
10
|
+
src/modify_csv.egg-info/SOURCES.txt
|
|
11
|
+
src/modify_csv.egg-info/dependency_links.txt
|
|
12
|
+
src/modify_csv.egg-info/entry_points.txt
|
|
13
|
+
src/modify_csv.egg-info/top_level.txt
|
|
14
|
+
tests/test_modify_csv.py
|
|
15
|
+
tests/test_release.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
modify_csv
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from contextlib import redirect_stderr, redirect_stdout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from modify_csv.cli import main
|
|
9
|
+
from modify_csv.errors import ScriptContractError
|
|
10
|
+
from modify_csv.script import load_evaluator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def write_csv(path: Path) -> None:
|
|
14
|
+
path.write_text("name,count\nalpha,1\nbeta,2\n", encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_csv(path: Path) -> list[list[str]]:
|
|
18
|
+
with path.open(newline="", encoding="utf-8") as stream:
|
|
19
|
+
return list(csv.reader(stream))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModifyCsvTests(unittest.TestCase):
|
|
23
|
+
def setUp(self) -> None:
|
|
24
|
+
self.temporary_directory = tempfile.TemporaryDirectory()
|
|
25
|
+
self.directory = Path(self.temporary_directory.name)
|
|
26
|
+
|
|
27
|
+
def tearDown(self) -> None:
|
|
28
|
+
self.temporary_directory.cleanup()
|
|
29
|
+
|
|
30
|
+
def test_inline_script_transforms_column_by_header(self) -> None:
|
|
31
|
+
source = self.directory / "input.csv"
|
|
32
|
+
output = self.directory / "output.csv"
|
|
33
|
+
write_csv(source)
|
|
34
|
+
|
|
35
|
+
result = main(
|
|
36
|
+
[
|
|
37
|
+
str(source),
|
|
38
|
+
str(output),
|
|
39
|
+
"--script",
|
|
40
|
+
"def eval(content, row, col):\n return int(content) + row + col",
|
|
41
|
+
"--column",
|
|
42
|
+
"count",
|
|
43
|
+
]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
self.assertEqual(result, 0)
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
read_csv(output),
|
|
49
|
+
[["name", "count"], ["alpha", "2"], ["beta", "4"]],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test_script_file_transforms_a_cell(self) -> None:
|
|
53
|
+
source = self.directory / "input.csv"
|
|
54
|
+
output = self.directory / "output.csv"
|
|
55
|
+
script = self.directory / "change.py"
|
|
56
|
+
write_csv(source)
|
|
57
|
+
script.write_text(
|
|
58
|
+
"def eval(content: object, row: int, col: int) -> object:\n"
|
|
59
|
+
" return f'{content}:{row}:{col}'\n",
|
|
60
|
+
encoding="utf-8",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
result = main(
|
|
64
|
+
[
|
|
65
|
+
str(source),
|
|
66
|
+
str(output),
|
|
67
|
+
"--script-file",
|
|
68
|
+
str(script),
|
|
69
|
+
"--cell",
|
|
70
|
+
"1",
|
|
71
|
+
"name",
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
self.assertEqual(result, 0)
|
|
76
|
+
self.assertEqual(read_csv(output)[2][0], "beta:1:0")
|
|
77
|
+
|
|
78
|
+
def test_row_transforms_every_cell(self) -> None:
|
|
79
|
+
source = self.directory / "input.csv"
|
|
80
|
+
output = self.directory / "output.csv"
|
|
81
|
+
write_csv(source)
|
|
82
|
+
|
|
83
|
+
result = main(
|
|
84
|
+
[
|
|
85
|
+
str(source),
|
|
86
|
+
str(output),
|
|
87
|
+
"--script",
|
|
88
|
+
"def eval(content, row, col): return content.upper()",
|
|
89
|
+
"--row",
|
|
90
|
+
"0",
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
self.assertEqual(result, 0)
|
|
95
|
+
self.assertEqual(read_csv(output)[1], ["ALPHA", "1"])
|
|
96
|
+
|
|
97
|
+
def test_rejects_wrong_eval_signature(self) -> None:
|
|
98
|
+
with self.assertRaisesRegex(ScriptContractError, "expected"):
|
|
99
|
+
load_evaluator("def eval(value): return value")
|
|
100
|
+
|
|
101
|
+
def test_supports_any_contract_annotation(self) -> None:
|
|
102
|
+
evaluator = load_evaluator(
|
|
103
|
+
"from typing import Any\n"
|
|
104
|
+
"def eval(content: Any, row: int, col: int) -> Any: return content"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
self.assertEqual(evaluator("value", 0, 0), "value")
|
|
108
|
+
|
|
109
|
+
def test_generates_script_template(self) -> None:
|
|
110
|
+
output = self.directory / "scripts" / "transform.py"
|
|
111
|
+
|
|
112
|
+
result = main(["template", str(output)])
|
|
113
|
+
|
|
114
|
+
self.assertEqual(result, 0)
|
|
115
|
+
source = output.read_text(encoding="utf-8")
|
|
116
|
+
self.assertIn("from typing import Any", source)
|
|
117
|
+
self.assertIn(
|
|
118
|
+
"def eval(content: Any, row: int, col: int) -> Any:", source
|
|
119
|
+
)
|
|
120
|
+
self.assertEqual(load_evaluator(source)("value", 0, 0), "value")
|
|
121
|
+
|
|
122
|
+
def test_help_displays_expected_signature(self) -> None:
|
|
123
|
+
output = io.StringIO()
|
|
124
|
+
|
|
125
|
+
with self.assertRaises(SystemExit) as raised, redirect_stdout(output):
|
|
126
|
+
main(["--help"])
|
|
127
|
+
|
|
128
|
+
self.assertEqual(raised.exception.code, 0)
|
|
129
|
+
self.assertIn(
|
|
130
|
+
"def eval(content: Any, row: int, col: int) -> Any:",
|
|
131
|
+
output.getvalue(),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def test_does_not_overwrite_without_force(self) -> None:
|
|
135
|
+
source = self.directory / "input.csv"
|
|
136
|
+
output = self.directory / "output.csv"
|
|
137
|
+
write_csv(source)
|
|
138
|
+
output.write_text("keep me", encoding="utf-8")
|
|
139
|
+
errors = io.StringIO()
|
|
140
|
+
|
|
141
|
+
with redirect_stderr(errors):
|
|
142
|
+
result = main(
|
|
143
|
+
[
|
|
144
|
+
str(source),
|
|
145
|
+
str(output),
|
|
146
|
+
"--script",
|
|
147
|
+
"def eval(content, row, col): return content",
|
|
148
|
+
"--row",
|
|
149
|
+
"0",
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
self.assertEqual(result, 1)
|
|
154
|
+
self.assertEqual(output.read_text(encoding="utf-8"), "keep me")
|
|
155
|
+
self.assertIn("--force", errors.getvalue())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
unittest.main()
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
|
|
4
|
+
from scripts.release import next_version, replace_version
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ReleaseScriptTests(unittest.TestCase):
|
|
8
|
+
def test_starts_monthly_release_at_one(self) -> None:
|
|
9
|
+
now = datetime(2026, 10, 1, tzinfo=timezone.utc)
|
|
10
|
+
|
|
11
|
+
self.assertEqual(next_version(["v2026.9.4", "unrelated"], now), "2026.10.1")
|
|
12
|
+
|
|
13
|
+
def test_increments_highest_release_for_current_month(self) -> None:
|
|
14
|
+
now = datetime(2026, 10, 1, tzinfo=timezone.utc)
|
|
15
|
+
|
|
16
|
+
self.assertEqual(
|
|
17
|
+
next_version(["v2026.10.1", "v2026.10.3", "v2025.10.9"], now),
|
|
18
|
+
"2026.10.4",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def test_replaces_project_version(self) -> None:
|
|
22
|
+
source = '[project]\nname = "modify-csv"\nversion = "0.1.0"\n'
|
|
23
|
+
|
|
24
|
+
self.assertEqual(
|
|
25
|
+
replace_version(source, "2026.10.1"),
|
|
26
|
+
'[project]\nname = "modify-csv"\nversion = "2026.10.1"\n',
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def test_rejects_missing_version(self) -> None:
|
|
30
|
+
with self.assertRaisesRegex(ValueError, "exactly one"):
|
|
31
|
+
replace_version('[project]\nname = "modify-csv"\n', "2026.10.1")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
unittest.main()
|