plainfile-project-check 0.1.0__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.
Files changed (25) hide show
  1. plainfile_project_check-0.1.0/LICENSE +25 -0
  2. plainfile_project_check-0.1.0/MANIFEST.in +3 -0
  3. plainfile_project_check-0.1.0/PKG-INFO +144 -0
  4. plainfile_project_check-0.1.0/README.md +102 -0
  5. plainfile_project_check-0.1.0/examples/after-clean.json +11 -0
  6. plainfile_project_check-0.1.0/examples/after-corrupted.json +13 -0
  7. plainfile_project_check-0.1.0/examples/before.json +11 -0
  8. plainfile_project_check-0.1.0/examples/csv-after-corrupted/projects.csv +4 -0
  9. plainfile_project_check-0.1.0/examples/csv-after-corrupted/tasks.csv +5 -0
  10. plainfile_project_check-0.1.0/examples/csv-before/projects.csv +3 -0
  11. plainfile_project_check-0.1.0/examples/csv-before/tasks.csv +4 -0
  12. plainfile_project_check-0.1.0/pyproject.toml +29 -0
  13. plainfile_project_check-0.1.0/setup.cfg +4 -0
  14. plainfile_project_check-0.1.0/src/plainfile_project_check/__init__.py +6 -0
  15. plainfile_project_check-0.1.0/src/plainfile_project_check/__main__.py +3 -0
  16. plainfile_project_check-0.1.0/src/plainfile_project_check/cli.py +64 -0
  17. plainfile_project_check-0.1.0/src/plainfile_project_check/core.py +173 -0
  18. plainfile_project_check-0.1.0/src/plainfile_project_check/csv_adapter.py +49 -0
  19. plainfile_project_check-0.1.0/src/plainfile_project_check/py.typed +1 -0
  20. plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/PKG-INFO +144 -0
  21. plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/SOURCES.txt +24 -0
  22. plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/dependency_links.txt +1 -0
  23. plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/entry_points.txt +2 -0
  24. plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/top_level.txt +1 -0
  25. plainfile_project_check-0.1.0/tests/test_project_check.py +127 -0
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Plainfile Tools contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This license grants lawful personal and commercial use. It does not promise
24
+ exclusive ownership of algorithms or AI-generated material, continuing
25
+ maintenance, custom development, or fitness for a particular migration.
@@ -0,0 +1,3 @@
1
+ include LICENSE README.md
2
+ recursive-include examples *.json *.csv
3
+ recursive-include tests *.py
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: plainfile-project-check
3
+ Version: 0.1.0
4
+ Summary: Offline stable-ID reconciliation for normalized project and task exports
5
+ Author-email: Plainfile Tools <plainfiletools@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Plainfile Tools contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ This license grants lawful personal and commercial use. It does not promise
29
+ exclusive ownership of algorithms or AI-generated material, continuing
30
+ maintenance, custom development, or fitness for a particular migration.
31
+
32
+ Keywords: csv,json,migration,reconciliation,data-quality
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3 :: Only
37
+ Classifier: Topic :: Utilities
38
+ Requires-Python: >=3.10
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Dynamic: license-file
42
+
43
+ # Plainfile Project Check
44
+
45
+ Plainfile Project Check is a free, open-source, dependency-free Python tool for comparing before-and-after project exports by stable ID. It runs locally, reads only the files you name, and emits a deterministic JSON report. It sends no data, performs no network requests, and includes no telemetry or runtime advertising.
46
+
47
+ It checks missing and unexpected project/task IDs, duplicate IDs in either snapshot, tasks whose project is absent, task-to-project reassignments, and exact-decimal `work_minutes` changes. A clean result exits `0`, differences exit `1`, and invalid input or a refused output exits `2`.
48
+
49
+ Requires Python 3.10 or newer.
50
+
51
+ ## Install an offline wheel
52
+
53
+ From the directory containing the downloaded wheel, install the supplied file without relying on package-registry availability, contacting an index, or resolving dependencies:
54
+
55
+ ```console
56
+ python -m pip install --no-index --no-deps plainfile_project_check-0.1.0-py3-none-any.whl
57
+ ```
58
+
59
+ To run directly from a source checkout without installing, set `PYTHONPATH` to the `src` directory, then use the module command:
60
+
61
+ ```powershell
62
+ $env:PYTHONPATH = "src"
63
+ python -m plainfile_project_check examples/before.json examples/after-clean.json
64
+ ```
65
+
66
+ ## Quick start with JSON
67
+
68
+ The normalized JSON format is exactly:
69
+
70
+ ```json
71
+ {
72
+ "projects": [{"id": "P-001", "name": "Plan"}],
73
+ "tasks": [{"id": "T-001", "project_id": "P-001", "name": "Task", "work_minutes": "60.50"}]
74
+ }
75
+ ```
76
+
77
+ From an installed package:
78
+
79
+ ```console
80
+ plainfile-project-check before.json after.json --output report.json
81
+ ```
82
+
83
+ The module form is equivalent:
84
+
85
+ ```console
86
+ python -m plainfile_project_check before.json after.json
87
+ ```
88
+
89
+ Omit `--output` to print the report to standard output. An output path must be new. The tool refuses to overwrite an existing output or either input.
90
+
91
+ ## CSV adapter
92
+
93
+ For each snapshot, create one directory containing these two UTF-8 CSV files with exactly these header names and order:
94
+
95
+ `projects.csv`
96
+
97
+ ```csv
98
+ id,name
99
+ P-001,"Plan, phase one"
100
+ ```
101
+
102
+ `tasks.csv`
103
+
104
+ ```csv
105
+ id,project_id,name,work_minutes
106
+ T-001,P-001,"Review, exact totals",60.50
107
+ ```
108
+
109
+ Then run:
110
+
111
+ ```console
112
+ plainfile-project-check before-dir after-dir --format csv --output report.json
113
+ ```
114
+
115
+ Ready-to-run synthetic directories are included:
116
+
117
+ ```console
118
+ plainfile-project-check examples/csv-before examples/csv-after-corrupted --format csv
119
+ ```
120
+
121
+ CSV values remain text, so IDs such as `0007` keep their leading zeros. The adapter uses ordinary comma-delimited CSV with double-quoted fields. It rejects missing, reordered, extra, or duplicate columns, malformed rows, invalid UTF-8, and invalid decimal work values.
122
+
123
+ Each input file is limited to 10 MB. IDs, names, and task project IDs must be non-empty strings. Work minutes must be finite, non-negative exact decimal text or a JSON integer, with at most 50 digits and a decimal exponent from -12 through 12. JSON duplicate object keys are rejected.
124
+
125
+ ## Scope and limitations
126
+
127
+ This tool accepts only user-supplied normalized JSON or the documented two-file CSV shape. It has no native Project Online, Microsoft Project, OData, XML, API, authentication, or extraction support. Exporting and mapping source-system data is your responsibility.
128
+
129
+ Names are validated but not compared. Tasks with duplicate IDs are reported and excluded from task reassignment and work comparison because identity is ambiguous. Duplicate project IDs are reported but do not suppress task comparisons. The report does not understand scheduling dependencies, calendars, resources, assignments, costs, custom fields, or fields omitted from the snapshots. A clean report does not certify a migration, prove data completeness, or establish legal authority to use the data. Keep source backups and review the report before changing another system.
130
+
131
+ The package code, documentation, examples, and tests were produced with substantial AI assistance under owner direction. All fixtures are original synthetic data; they contain no client data and demonstrate no client results. The package does not promise automatic updates, custom maintenance, or support.
132
+
133
+ The MIT license permits personal and commercial use, modification, and redistribution with its notice. It does not claim exclusive human authorship or exclusive ownership of general algorithms or AI-generated material.
134
+
135
+ If you need a separate pre-import CSV cleanup utility, Plainfile Tools sells [CSV Import Check for $19](https://plainfiletools.gumroad.com/l/csv-import-check?utm_source=pypi&utm_medium=package&utm_campaign=project_check_v010). It is a distinct product, is not required for this package, and does not unlock features here.
136
+
137
+ Contact: plainfiletools@gmail.com
138
+
139
+ ## Development
140
+
141
+ ```console
142
+ python -m unittest discover -s tests -v
143
+ python -m build --no-isolation
144
+ ```
@@ -0,0 +1,102 @@
1
+ # Plainfile Project Check
2
+
3
+ Plainfile Project Check is a free, open-source, dependency-free Python tool for comparing before-and-after project exports by stable ID. It runs locally, reads only the files you name, and emits a deterministic JSON report. It sends no data, performs no network requests, and includes no telemetry or runtime advertising.
4
+
5
+ It checks missing and unexpected project/task IDs, duplicate IDs in either snapshot, tasks whose project is absent, task-to-project reassignments, and exact-decimal `work_minutes` changes. A clean result exits `0`, differences exit `1`, and invalid input or a refused output exits `2`.
6
+
7
+ Requires Python 3.10 or newer.
8
+
9
+ ## Install an offline wheel
10
+
11
+ From the directory containing the downloaded wheel, install the supplied file without relying on package-registry availability, contacting an index, or resolving dependencies:
12
+
13
+ ```console
14
+ python -m pip install --no-index --no-deps plainfile_project_check-0.1.0-py3-none-any.whl
15
+ ```
16
+
17
+ To run directly from a source checkout without installing, set `PYTHONPATH` to the `src` directory, then use the module command:
18
+
19
+ ```powershell
20
+ $env:PYTHONPATH = "src"
21
+ python -m plainfile_project_check examples/before.json examples/after-clean.json
22
+ ```
23
+
24
+ ## Quick start with JSON
25
+
26
+ The normalized JSON format is exactly:
27
+
28
+ ```json
29
+ {
30
+ "projects": [{"id": "P-001", "name": "Plan"}],
31
+ "tasks": [{"id": "T-001", "project_id": "P-001", "name": "Task", "work_minutes": "60.50"}]
32
+ }
33
+ ```
34
+
35
+ From an installed package:
36
+
37
+ ```console
38
+ plainfile-project-check before.json after.json --output report.json
39
+ ```
40
+
41
+ The module form is equivalent:
42
+
43
+ ```console
44
+ python -m plainfile_project_check before.json after.json
45
+ ```
46
+
47
+ Omit `--output` to print the report to standard output. An output path must be new. The tool refuses to overwrite an existing output or either input.
48
+
49
+ ## CSV adapter
50
+
51
+ For each snapshot, create one directory containing these two UTF-8 CSV files with exactly these header names and order:
52
+
53
+ `projects.csv`
54
+
55
+ ```csv
56
+ id,name
57
+ P-001,"Plan, phase one"
58
+ ```
59
+
60
+ `tasks.csv`
61
+
62
+ ```csv
63
+ id,project_id,name,work_minutes
64
+ T-001,P-001,"Review, exact totals",60.50
65
+ ```
66
+
67
+ Then run:
68
+
69
+ ```console
70
+ plainfile-project-check before-dir after-dir --format csv --output report.json
71
+ ```
72
+
73
+ Ready-to-run synthetic directories are included:
74
+
75
+ ```console
76
+ plainfile-project-check examples/csv-before examples/csv-after-corrupted --format csv
77
+ ```
78
+
79
+ CSV values remain text, so IDs such as `0007` keep their leading zeros. The adapter uses ordinary comma-delimited CSV with double-quoted fields. It rejects missing, reordered, extra, or duplicate columns, malformed rows, invalid UTF-8, and invalid decimal work values.
80
+
81
+ Each input file is limited to 10 MB. IDs, names, and task project IDs must be non-empty strings. Work minutes must be finite, non-negative exact decimal text or a JSON integer, with at most 50 digits and a decimal exponent from -12 through 12. JSON duplicate object keys are rejected.
82
+
83
+ ## Scope and limitations
84
+
85
+ This tool accepts only user-supplied normalized JSON or the documented two-file CSV shape. It has no native Project Online, Microsoft Project, OData, XML, API, authentication, or extraction support. Exporting and mapping source-system data is your responsibility.
86
+
87
+ Names are validated but not compared. Tasks with duplicate IDs are reported and excluded from task reassignment and work comparison because identity is ambiguous. Duplicate project IDs are reported but do not suppress task comparisons. The report does not understand scheduling dependencies, calendars, resources, assignments, costs, custom fields, or fields omitted from the snapshots. A clean report does not certify a migration, prove data completeness, or establish legal authority to use the data. Keep source backups and review the report before changing another system.
88
+
89
+ The package code, documentation, examples, and tests were produced with substantial AI assistance under owner direction. All fixtures are original synthetic data; they contain no client data and demonstrate no client results. The package does not promise automatic updates, custom maintenance, or support.
90
+
91
+ The MIT license permits personal and commercial use, modification, and redistribution with its notice. It does not claim exclusive human authorship or exclusive ownership of general algorithms or AI-generated material.
92
+
93
+ If you need a separate pre-import CSV cleanup utility, Plainfile Tools sells [CSV Import Check for $19](https://plainfiletools.gumroad.com/l/csv-import-check?utm_source=pypi&utm_medium=package&utm_campaign=project_check_v010). It is a distinct product, is not required for this package, and does not unlock features here.
94
+
95
+ Contact: plainfiletools@gmail.com
96
+
97
+ ## Development
98
+
99
+ ```console
100
+ python -m unittest discover -s tests -v
101
+ python -m build --no-isolation
102
+ ```
@@ -0,0 +1,11 @@
1
+ {
2
+ "projects": [
3
+ {"id": "P-100", "name": "Website rollout"},
4
+ {"id": "P-200", "name": "Warehouse refresh"}
5
+ ],
6
+ "tasks": [
7
+ {"id": "T-101", "project_id": "P-100", "name": "Discovery", "work_minutes": "480"},
8
+ {"id": "T-102", "project_id": "P-100", "name": "Build", "work_minutes": "1200.50"},
9
+ {"id": "T-201", "project_id": "P-200", "name": "Inventory", "work_minutes": "300"}
10
+ ]
11
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "projects": [
3
+ {"id": "P-100", "name": "Website rollout"},
4
+ {"id": "P-300", "name": "Unexpected project"},
5
+ {"id": "P-300", "name": "Duplicate project"}
6
+ ],
7
+ "tasks": [
8
+ {"id": "T-101", "project_id": "P-300", "name": "Discovery", "work_minutes": "510"},
9
+ {"id": "T-102", "project_id": "P-999", "name": "Build", "work_minutes": "1200.50"},
10
+ {"id": "T-NEW", "project_id": "P-100", "name": "Unexpected task", "work_minutes": "15"},
11
+ {"id": "T-NEW", "project_id": "P-100", "name": "Duplicate task", "work_minutes": "15"}
12
+ ]
13
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "projects": [
3
+ {"id": "P-100", "name": "Website rollout"},
4
+ {"id": "P-200", "name": "Warehouse refresh"}
5
+ ],
6
+ "tasks": [
7
+ {"id": "T-101", "project_id": "P-100", "name": "Discovery", "work_minutes": "480"},
8
+ {"id": "T-102", "project_id": "P-100", "name": "Build", "work_minutes": "1200.50"},
9
+ {"id": "T-201", "project_id": "P-200", "name": "Inventory", "work_minutes": "300"}
10
+ ]
11
+ }
@@ -0,0 +1,4 @@
1
+ id,name
2
+ P-001,"Website, phase one"
3
+ P-003,Unexpected project
4
+ P-003,Duplicate project
@@ -0,0 +1,5 @@
1
+ id,project_id,name,work_minutes
2
+ 000101,P-003,Discovery,510
3
+ 000102,P-999,"Build, review",1200.50
4
+ 000999,P-001,Unexpected task,15
5
+ 000999,P-001,Duplicate task,15
@@ -0,0 +1,3 @@
1
+ id,name
2
+ P-001,"Website, phase one"
3
+ P-002,Warehouse refresh
@@ -0,0 +1,4 @@
1
+ id,project_id,name,work_minutes
2
+ 000101,P-001,Discovery,480
3
+ 000102,P-001,"Build, review",1200.50
4
+ 000201,P-002,Inventory,300
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "plainfile-project-check"
7
+ version = "0.1.0"
8
+ description = "Offline stable-ID reconciliation for normalized project and task exports"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Plainfile Tools", email = "plainfiletools@gmail.com" }]
13
+ keywords = ["csv", "json", "migration", "reconciliation", "data-quality"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Utilities",
20
+ ]
21
+
22
+ [project.scripts]
23
+ plainfile-project-check = "plainfile_project_check.cli:main"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ plainfile_project_check = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Plainfile Project Check public API."""
2
+
3
+ from .core import InputError, load_export, reconcile
4
+
5
+ __all__ = ["InputError", "load_export", "reconcile"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,64 @@
1
+ """Command-line interface for Plainfile Project Check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+ import sys
9
+
10
+ from .core import InputError, load_export, reconcile
11
+ from .csv_adapter import load_csv_export
12
+
13
+
14
+ def build_parser() -> argparse.ArgumentParser:
15
+ parser = argparse.ArgumentParser(
16
+ prog="plainfile-project-check",
17
+ description="Compare two normalized project/task snapshots offline by stable ID.")
18
+ parser.add_argument("before", type=Path, help="JSON file, or CSV directory with --format csv")
19
+ parser.add_argument("after", type=Path, help="JSON file, or CSV directory with --format csv")
20
+ parser.add_argument("--format", choices=("json", "csv"), default="json")
21
+ parser.add_argument("--output", type=Path, help="new report file; stdout when omitted")
22
+ return parser
23
+
24
+
25
+ def _same_path(left: Path, right: Path) -> bool:
26
+ try:
27
+ return left.resolve(strict=False) == right.resolve(strict=False)
28
+ except OSError:
29
+ return left.absolute() == right.absolute()
30
+
31
+
32
+ def _write_new(path: Path, text: str, inputs: list[Path]) -> None:
33
+ if any(_same_path(path, item) for item in inputs):
34
+ raise InputError(f"refusing to overwrite an input: {path}")
35
+ try:
36
+ with path.open("x", encoding="utf-8", newline="\n") as handle:
37
+ handle.write(text)
38
+ except FileExistsError as exc:
39
+ raise InputError(f"refusing to overwrite existing output: {path}") from exc
40
+ except OSError as exc:
41
+ raise InputError(f"cannot create output {path}: {exc}") from exc
42
+
43
+
44
+ def main(argv: list[str] | None = None) -> int:
45
+ parser = build_parser()
46
+ args = parser.parse_args(argv)
47
+ try:
48
+ loader = load_export if args.format == "json" else load_csv_export
49
+ before = loader(args.before)
50
+ after = loader(args.after)
51
+ report = reconcile(before, after)
52
+ # ASCII escapes keep stdout portable on Windows consoles with legacy encodings.
53
+ rendered = json.dumps(report, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
54
+ if args.output:
55
+ input_files = ([args.before, args.after] if args.format == "json" else
56
+ [args.before / "projects.csv", args.before / "tasks.csv",
57
+ args.after / "projects.csv", args.after / "tasks.csv"])
58
+ _write_new(args.output, rendered, input_files)
59
+ else:
60
+ sys.stdout.write(rendered)
61
+ return 0 if report["status"] == "clean" else 1
62
+ except InputError as exc:
63
+ print(f"error: {exc}", file=sys.stderr)
64
+ return 2
@@ -0,0 +1,173 @@
1
+ """Offline reconciliation for normalized project/task snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from decimal import Decimal, InvalidOperation, localcontext
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ MAX_BYTES = 10 * 1024 * 1024
12
+
13
+
14
+ class InputError(ValueError):
15
+ """An input is unreadable or does not match the documented schema."""
16
+
17
+
18
+ def _reject_constant(value: str) -> None:
19
+ raise InputError(f"non-finite JSON number is not allowed: {value}")
20
+
21
+
22
+ def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
23
+ result: dict[str, Any] = {}
24
+ for key, value in pairs:
25
+ if key in result:
26
+ raise InputError(f"duplicate JSON object key is not allowed: {key}")
27
+ result[key] = value
28
+ return result
29
+
30
+
31
+ def check_input_file(path: Path) -> None:
32
+ try:
33
+ size = path.stat().st_size
34
+ except OSError as exc:
35
+ raise InputError(f"cannot access {path}: {exc}") from exc
36
+ if not path.is_file():
37
+ raise InputError(f"input is not a regular file: {path}")
38
+ if size > MAX_BYTES:
39
+ raise InputError(f"input exceeds the 10 MB limit: {path}")
40
+
41
+
42
+ def load_export(path: Path) -> dict[str, list[dict[str, Any]]]:
43
+ check_input_file(path)
44
+ try:
45
+ with path.open("r", encoding="utf-8-sig") as handle:
46
+ value = json.load(handle, parse_float=Decimal, parse_int=Decimal,
47
+ parse_constant=_reject_constant, object_pairs_hook=_object)
48
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
49
+ raise InputError(f"cannot read {path}: {exc}") from exc
50
+ validate_export(value, str(path))
51
+ return value
52
+
53
+
54
+ def _string(record: dict[str, Any], field: str, where: str) -> str:
55
+ value = record.get(field)
56
+ if not isinstance(value, str) or not value.strip():
57
+ raise InputError(f"{where}.{field} must be a non-empty string")
58
+ return value
59
+
60
+
61
+ def parse_work(value: Any, where: str) -> Decimal:
62
+ if isinstance(value, bool) or not isinstance(value, (str, int, Decimal)):
63
+ raise InputError(f"{where}.work_minutes must be an integer or decimal string")
64
+ try:
65
+ amount = Decimal(str(value))
66
+ except InvalidOperation as exc:
67
+ raise InputError(f"{where}.work_minutes is not a decimal") from exc
68
+ if not amount.is_finite() or amount < 0:
69
+ raise InputError(f"{where}.work_minutes must be finite and non-negative")
70
+ digits, exponent = len(amount.as_tuple().digits), amount.as_tuple().exponent
71
+ if digits > 50 or exponent < -12 or exponent > 12:
72
+ raise InputError(f"{where}.work_minutes exceeds 50 digits or exponent range -12..12")
73
+ return amount
74
+
75
+
76
+ def validate_export(value: Any, label: str = "export") -> None:
77
+ if not isinstance(value, dict) or set(value) != {"projects", "tasks"}:
78
+ raise InputError(f"{label} must contain exactly 'projects' and 'tasks' arrays")
79
+ for collection in ("projects", "tasks"):
80
+ records = value[collection]
81
+ if not isinstance(records, list):
82
+ raise InputError(f"{label}.{collection} must be an array")
83
+ expected = ({"id", "name"} if collection == "projects" else
84
+ {"id", "project_id", "name", "work_minutes"})
85
+ for index, record in enumerate(records):
86
+ where = f"{label}.{collection}[{index}]"
87
+ if not isinstance(record, dict) or set(record) != expected:
88
+ raise InputError(f"{where} must contain exactly {sorted(expected)}")
89
+ _string(record, "id", where)
90
+ _string(record, "name", where)
91
+ if collection == "tasks":
92
+ _string(record, "project_id", where)
93
+ parse_work(record["work_minutes"], where)
94
+
95
+
96
+ def _duplicates(records: list[dict[str, Any]]) -> list[str]:
97
+ counts = Counter(record["id"] for record in records)
98
+ return sorted(key for key, count in counts.items() if count > 1)
99
+
100
+
101
+ def _unique(records: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
102
+ counts = Counter(record["id"] for record in records)
103
+ return {record["id"]: record for record in records if counts[record["id"]] == 1}
104
+
105
+
106
+ def _decimal_text(value: Decimal) -> str:
107
+ text = format(value, "f")
108
+ return text.rstrip("0").rstrip(".") if "." in text else text
109
+
110
+
111
+ def reconcile(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
112
+ validate_export(before, "before")
113
+ validate_export(after, "after")
114
+ before_tasks, after_tasks = _unique(before["tasks"]), _unique(after["tasks"])
115
+ before_projects = {row["id"] for row in before["projects"]}
116
+ after_projects = {row["id"] for row in after["projects"]}
117
+ before_task_ids = {row["id"] for row in before["tasks"]}
118
+ after_task_ids = {row["id"] for row in after["tasks"]}
119
+ common = sorted(set(before_tasks) & set(after_tasks))
120
+ deltas: list[dict[str, str]] = []
121
+ with localcontext() as context:
122
+ context.prec = 128
123
+ for task_id in common:
124
+ old = parse_work(before_tasks[task_id]["work_minutes"], "before task")
125
+ new = parse_work(after_tasks[task_id]["work_minutes"], "after task")
126
+ if old != new:
127
+ deltas.append({"task_id": task_id, "before": _decimal_text(old),
128
+ "after": _decimal_text(new), "delta": _decimal_text(new - old)})
129
+ findings: dict[str, Any] = {
130
+ "duplicate_project_ids": {"before": _duplicates(before["projects"]),
131
+ "after": _duplicates(after["projects"])},
132
+ "duplicate_task_ids": {"before": _duplicates(before["tasks"]),
133
+ "after": _duplicates(after["tasks"])},
134
+ "missing_project_ids": sorted(before_projects - after_projects),
135
+ "unexpected_project_ids": sorted(after_projects - before_projects),
136
+ "missing_task_ids": sorted(before_task_ids - after_task_ids),
137
+ "unexpected_task_ids": sorted(after_task_ids - before_task_ids),
138
+ "orphan_tasks": {
139
+ "before": sorted(row["id"] for row in before["tasks"]
140
+ if row["project_id"] not in before_projects),
141
+ "after": sorted(row["id"] for row in after["tasks"]
142
+ if row["project_id"] not in after_projects),
143
+ },
144
+ "task_project_reassignments": [
145
+ {"task_id": task_id,
146
+ "before_project_id": before_tasks[task_id]["project_id"],
147
+ "after_project_id": after_tasks[task_id]["project_id"]}
148
+ for task_id in common
149
+ if before_tasks[task_id]["project_id"] != after_tasks[task_id]["project_id"]
150
+ ],
151
+ "work_minutes_deltas": deltas,
152
+ }
153
+ count = sum(_count(value) for value in findings.values())
154
+ return {
155
+ "status": "clean" if count == 0 else "differences_found",
156
+ "counts": {"before_projects": len(before["projects"]),
157
+ "after_projects": len(after["projects"]),
158
+ "before_tasks": len(before["tasks"]),
159
+ "after_tasks": len(after["tasks"]), "findings": count},
160
+ "findings": findings,
161
+ "limitations": [
162
+ "Compares only the supplied normalized fields and snapshots.",
163
+ "No native Project Online, Microsoft Project, OData, XML, API, or extraction support.",
164
+ "Names are validated but not compared; duplicate task IDs skip field-level comparison.",
165
+ "A clean result does not certify a migration or detect omitted data.",
166
+ ],
167
+ }
168
+
169
+
170
+ def _count(value: Any) -> int:
171
+ if isinstance(value, dict):
172
+ return sum(_count(item) for item in value.values())
173
+ return len(value)
@@ -0,0 +1,49 @@
1
+ """Strict CSV adapter that preserves every cell as text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .core import InputError, check_input_file, parse_work, validate_export
10
+
11
+ HEADERS = {
12
+ "projects": ["id", "name"],
13
+ "tasks": ["id", "project_id", "name", "work_minutes"],
14
+ }
15
+
16
+
17
+ def _read(path: Path, kind: str) -> list[dict[str, str]]:
18
+ check_input_file(path)
19
+ try:
20
+ with path.open("r", encoding="utf-8-sig", newline="") as handle:
21
+ reader = csv.reader(handle, strict=True)
22
+ try:
23
+ header = next(reader)
24
+ except StopIteration as exc:
25
+ raise InputError(f"{path} is empty") from exc
26
+ if len(header) != len(set(header)):
27
+ raise InputError(f"{path} has duplicate column names")
28
+ if header != HEADERS[kind]:
29
+ raise InputError(f"{path} headers must be exactly {','.join(HEADERS[kind])}")
30
+ rows: list[dict[str, str]] = []
31
+ for number, values in enumerate(reader, 2):
32
+ if len(values) != len(header):
33
+ raise InputError(f"{path} row {number} has {len(values)} fields; expected {len(header)}")
34
+ row = dict(zip(header, values))
35
+ if kind == "tasks":
36
+ parse_work(row["work_minutes"], f"{path} row {number}")
37
+ rows.append(row)
38
+ return rows
39
+ except (OSError, UnicodeError, csv.Error) as exc:
40
+ raise InputError(f"cannot read {path}: {exc}") from exc
41
+
42
+
43
+ def load_csv_export(directory: Path) -> dict[str, list[dict[str, Any]]]:
44
+ if not directory.is_dir():
45
+ raise InputError(f"CSV input must be a directory: {directory}")
46
+ value = {"projects": _read(directory / "projects.csv", "projects"),
47
+ "tasks": _read(directory / "tasks.csv", "tasks")}
48
+ validate_export(value, str(directory))
49
+ return value
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: plainfile-project-check
3
+ Version: 0.1.0
4
+ Summary: Offline stable-ID reconciliation for normalized project and task exports
5
+ Author-email: Plainfile Tools <plainfiletools@gmail.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Plainfile Tools contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ This license grants lawful personal and commercial use. It does not promise
29
+ exclusive ownership of algorithms or AI-generated material, continuing
30
+ maintenance, custom development, or fitness for a particular migration.
31
+
32
+ Keywords: csv,json,migration,reconciliation,data-quality
33
+ Classifier: Development Status :: 3 - Alpha
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3 :: Only
37
+ Classifier: Topic :: Utilities
38
+ Requires-Python: >=3.10
39
+ Description-Content-Type: text/markdown
40
+ License-File: LICENSE
41
+ Dynamic: license-file
42
+
43
+ # Plainfile Project Check
44
+
45
+ Plainfile Project Check is a free, open-source, dependency-free Python tool for comparing before-and-after project exports by stable ID. It runs locally, reads only the files you name, and emits a deterministic JSON report. It sends no data, performs no network requests, and includes no telemetry or runtime advertising.
46
+
47
+ It checks missing and unexpected project/task IDs, duplicate IDs in either snapshot, tasks whose project is absent, task-to-project reassignments, and exact-decimal `work_minutes` changes. A clean result exits `0`, differences exit `1`, and invalid input or a refused output exits `2`.
48
+
49
+ Requires Python 3.10 or newer.
50
+
51
+ ## Install an offline wheel
52
+
53
+ From the directory containing the downloaded wheel, install the supplied file without relying on package-registry availability, contacting an index, or resolving dependencies:
54
+
55
+ ```console
56
+ python -m pip install --no-index --no-deps plainfile_project_check-0.1.0-py3-none-any.whl
57
+ ```
58
+
59
+ To run directly from a source checkout without installing, set `PYTHONPATH` to the `src` directory, then use the module command:
60
+
61
+ ```powershell
62
+ $env:PYTHONPATH = "src"
63
+ python -m plainfile_project_check examples/before.json examples/after-clean.json
64
+ ```
65
+
66
+ ## Quick start with JSON
67
+
68
+ The normalized JSON format is exactly:
69
+
70
+ ```json
71
+ {
72
+ "projects": [{"id": "P-001", "name": "Plan"}],
73
+ "tasks": [{"id": "T-001", "project_id": "P-001", "name": "Task", "work_minutes": "60.50"}]
74
+ }
75
+ ```
76
+
77
+ From an installed package:
78
+
79
+ ```console
80
+ plainfile-project-check before.json after.json --output report.json
81
+ ```
82
+
83
+ The module form is equivalent:
84
+
85
+ ```console
86
+ python -m plainfile_project_check before.json after.json
87
+ ```
88
+
89
+ Omit `--output` to print the report to standard output. An output path must be new. The tool refuses to overwrite an existing output or either input.
90
+
91
+ ## CSV adapter
92
+
93
+ For each snapshot, create one directory containing these two UTF-8 CSV files with exactly these header names and order:
94
+
95
+ `projects.csv`
96
+
97
+ ```csv
98
+ id,name
99
+ P-001,"Plan, phase one"
100
+ ```
101
+
102
+ `tasks.csv`
103
+
104
+ ```csv
105
+ id,project_id,name,work_minutes
106
+ T-001,P-001,"Review, exact totals",60.50
107
+ ```
108
+
109
+ Then run:
110
+
111
+ ```console
112
+ plainfile-project-check before-dir after-dir --format csv --output report.json
113
+ ```
114
+
115
+ Ready-to-run synthetic directories are included:
116
+
117
+ ```console
118
+ plainfile-project-check examples/csv-before examples/csv-after-corrupted --format csv
119
+ ```
120
+
121
+ CSV values remain text, so IDs such as `0007` keep their leading zeros. The adapter uses ordinary comma-delimited CSV with double-quoted fields. It rejects missing, reordered, extra, or duplicate columns, malformed rows, invalid UTF-8, and invalid decimal work values.
122
+
123
+ Each input file is limited to 10 MB. IDs, names, and task project IDs must be non-empty strings. Work minutes must be finite, non-negative exact decimal text or a JSON integer, with at most 50 digits and a decimal exponent from -12 through 12. JSON duplicate object keys are rejected.
124
+
125
+ ## Scope and limitations
126
+
127
+ This tool accepts only user-supplied normalized JSON or the documented two-file CSV shape. It has no native Project Online, Microsoft Project, OData, XML, API, authentication, or extraction support. Exporting and mapping source-system data is your responsibility.
128
+
129
+ Names are validated but not compared. Tasks with duplicate IDs are reported and excluded from task reassignment and work comparison because identity is ambiguous. Duplicate project IDs are reported but do not suppress task comparisons. The report does not understand scheduling dependencies, calendars, resources, assignments, costs, custom fields, or fields omitted from the snapshots. A clean report does not certify a migration, prove data completeness, or establish legal authority to use the data. Keep source backups and review the report before changing another system.
130
+
131
+ The package code, documentation, examples, and tests were produced with substantial AI assistance under owner direction. All fixtures are original synthetic data; they contain no client data and demonstrate no client results. The package does not promise automatic updates, custom maintenance, or support.
132
+
133
+ The MIT license permits personal and commercial use, modification, and redistribution with its notice. It does not claim exclusive human authorship or exclusive ownership of general algorithms or AI-generated material.
134
+
135
+ If you need a separate pre-import CSV cleanup utility, Plainfile Tools sells [CSV Import Check for $19](https://plainfiletools.gumroad.com/l/csv-import-check?utm_source=pypi&utm_medium=package&utm_campaign=project_check_v010). It is a distinct product, is not required for this package, and does not unlock features here.
136
+
137
+ Contact: plainfiletools@gmail.com
138
+
139
+ ## Development
140
+
141
+ ```console
142
+ python -m unittest discover -s tests -v
143
+ python -m build --no-isolation
144
+ ```
@@ -0,0 +1,24 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ setup.cfg
6
+ examples/after-clean.json
7
+ examples/after-corrupted.json
8
+ examples/before.json
9
+ examples/csv-after-corrupted/projects.csv
10
+ examples/csv-after-corrupted/tasks.csv
11
+ examples/csv-before/projects.csv
12
+ examples/csv-before/tasks.csv
13
+ src/plainfile_project_check/__init__.py
14
+ src/plainfile_project_check/__main__.py
15
+ src/plainfile_project_check/cli.py
16
+ src/plainfile_project_check/core.py
17
+ src/plainfile_project_check/csv_adapter.py
18
+ src/plainfile_project_check/py.typed
19
+ src/plainfile_project_check.egg-info/PKG-INFO
20
+ src/plainfile_project_check.egg-info/SOURCES.txt
21
+ src/plainfile_project_check.egg-info/dependency_links.txt
22
+ src/plainfile_project_check.egg-info/entry_points.txt
23
+ src/plainfile_project_check.egg-info/top_level.txt
24
+ tests/test_project_check.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ plainfile-project-check = plainfile_project_check.cli:main
@@ -0,0 +1,127 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import subprocess
5
+ import sys
6
+ from tempfile import TemporaryDirectory
7
+ import unittest
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from plainfile_project_check.cli import main
12
+ from plainfile_project_check.core import InputError, load_export, reconcile
13
+ from plainfile_project_check.csv_adapter import load_csv_export
14
+
15
+
16
+ def snapshot(projects=None, tasks=None):
17
+ return {
18
+ "projects": projects if projects is not None else [{"id": "P-001", "name": "Plan"}],
19
+ "tasks": tasks if tasks is not None else [
20
+ {"id": "T-001", "project_id": "P-001", "name": "Work", "work_minutes": "60.00"}
21
+ ],
22
+ }
23
+
24
+
25
+ class EngineTests(unittest.TestCase):
26
+ def test_clean_and_corrupted_synthetic_fixtures(self):
27
+ root = Path(__file__).resolve().parents[1] / "examples"
28
+ before = load_export(root / "before.json")
29
+ self.assertEqual("clean", reconcile(before, load_export(root / "after-clean.json"))["status"])
30
+ report = reconcile(before, load_export(root / "after-corrupted.json"))
31
+ self.assertEqual("differences_found", report["status"])
32
+ self.assertEqual(["P-200"], report["findings"]["missing_project_ids"])
33
+ self.assertEqual(["P-300"], report["findings"]["duplicate_project_ids"]["after"])
34
+ self.assertEqual(["T-102"], report["findings"]["orphan_tasks"]["after"])
35
+ self.assertEqual("30", report["findings"]["work_minutes_deltas"][0]["delta"])
36
+
37
+ def test_exact_decimal_and_reassignment(self):
38
+ projects = [{"id": "P-001", "name": "One"}, {"id": "P-002", "name": "Two"}]
39
+ before = snapshot(projects, [{"id": "T", "project_id": "P-001", "name": "x",
40
+ "work_minutes": "0.100000000001"}])
41
+ after = snapshot(projects, [{"id": "T", "project_id": "P-002", "name": "x",
42
+ "work_minutes": "0.100000000002"}])
43
+ findings = reconcile(before, after)["findings"]
44
+ self.assertEqual("0.000000000001", findings["work_minutes_deltas"][0]["delta"])
45
+ self.assertEqual("P-002", findings["task_project_reassignments"][0]["after_project_id"])
46
+
47
+
48
+ class CsvAdapterTests(unittest.TestCase):
49
+ def _pair(self, root, project_header="id,name", task_header="id,project_id,name,work_minutes",
50
+ project_row='0007,"Quoted, project"', task_row='0009,0007,"Quoted, task",1.25'):
51
+ root.mkdir()
52
+ (root / "projects.csv").write_text(project_header + "\n" + project_row + "\n", encoding="utf-8")
53
+ (root / "tasks.csv").write_text(task_header + "\n" + task_row + "\n", encoding="utf-8")
54
+
55
+ def test_preserves_leading_zero_ids_and_quoted_commas(self):
56
+ with TemporaryDirectory() as temp:
57
+ directory = Path(temp) / "input"
58
+ self._pair(directory)
59
+ value = load_csv_export(directory)
60
+ self.assertEqual("0007", value["projects"][0]["id"])
61
+ self.assertEqual("Quoted, project", value["projects"][0]["name"])
62
+ self.assertEqual("0009", value["tasks"][0]["id"])
63
+
64
+ def test_missing_header_is_rejected(self):
65
+ with TemporaryDirectory() as temp:
66
+ directory = Path(temp) / "input"
67
+ self._pair(directory, project_header="id")
68
+ with self.assertRaises(InputError):
69
+ load_csv_export(directory)
70
+
71
+ def test_duplicate_columns_are_rejected(self):
72
+ with TemporaryDirectory() as temp:
73
+ directory = Path(temp) / "input"
74
+ self._pair(directory, task_header="id,project_id,name,name")
75
+ with self.assertRaisesRegex(InputError, "duplicate column"):
76
+ load_csv_export(directory)
77
+
78
+ def test_invalid_decimal_is_rejected(self):
79
+ with TemporaryDirectory() as temp:
80
+ directory = Path(temp) / "input"
81
+ self._pair(directory, task_row="0009,0007,Task,NaN")
82
+ with self.assertRaises(InputError):
83
+ load_csv_export(directory)
84
+
85
+
86
+ class CliTests(unittest.TestCase):
87
+ def test_refuses_existing_output_and_input_overwrite(self):
88
+ root = Path(__file__).resolve().parents[1] / "examples"
89
+ with TemporaryDirectory() as temp:
90
+ output = Path(temp) / "report.json"
91
+ output.write_text("keep", encoding="utf-8")
92
+ self.assertEqual(2, main([str(root / "before.json"), str(root / "after-clean.json"),
93
+ "--output", str(output)]))
94
+ self.assertEqual("keep", output.read_text(encoding="utf-8"))
95
+ original = (root / "before.json").read_text(encoding="utf-8")
96
+ self.assertEqual(2, main([str(root / "before.json"), str(root / "after-clean.json"),
97
+ "--output", str(root / "before.json")]))
98
+ self.assertEqual(original, (root / "before.json").read_text(encoding="utf-8"))
99
+
100
+ def test_new_output_and_exit_statuses(self):
101
+ root = Path(__file__).resolve().parents[1] / "examples"
102
+ with TemporaryDirectory() as temp:
103
+ clean = Path(temp) / "clean.json"
104
+ self.assertEqual(0, main([str(root / "before.json"), str(root / "after-clean.json"),
105
+ "--output", str(clean)]))
106
+ self.assertEqual("clean", json.loads(clean.read_text(encoding="utf-8"))["status"])
107
+ self.assertEqual(1, main([str(root / "before.json"), str(root / "after-corrupted.json")]))
108
+
109
+ def test_non_ascii_output_survives_ascii_console(self):
110
+ with TemporaryDirectory() as temp:
111
+ root = Path(temp)
112
+ before = snapshot(projects=[{"id": "P-caf\u00e9", "name": "Plan"}], tasks=[])
113
+ after = snapshot(projects=[], tasks=[])
114
+ (root / "before.json").write_text(json.dumps(before, ensure_ascii=False), encoding="utf-8")
115
+ (root / "after.json").write_text(json.dumps(after, ensure_ascii=False), encoding="utf-8")
116
+ environment = os.environ.copy()
117
+ environment["PYTHONIOENCODING"] = "ascii"
118
+ environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src")
119
+ result = subprocess.run(
120
+ [sys.executable, "-m", "plainfile_project_check", str(root / "before.json"),
121
+ str(root / "after.json")], capture_output=True, text=True, env=environment)
122
+ self.assertEqual(1, result.returncode, result.stderr)
123
+ self.assertIn("P-caf\\u00e9", result.stdout)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()