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.
- plainfile_project_check-0.1.0/LICENSE +25 -0
- plainfile_project_check-0.1.0/MANIFEST.in +3 -0
- plainfile_project_check-0.1.0/PKG-INFO +144 -0
- plainfile_project_check-0.1.0/README.md +102 -0
- plainfile_project_check-0.1.0/examples/after-clean.json +11 -0
- plainfile_project_check-0.1.0/examples/after-corrupted.json +13 -0
- plainfile_project_check-0.1.0/examples/before.json +11 -0
- plainfile_project_check-0.1.0/examples/csv-after-corrupted/projects.csv +4 -0
- plainfile_project_check-0.1.0/examples/csv-after-corrupted/tasks.csv +5 -0
- plainfile_project_check-0.1.0/examples/csv-before/projects.csv +3 -0
- plainfile_project_check-0.1.0/examples/csv-before/tasks.csv +4 -0
- plainfile_project_check-0.1.0/pyproject.toml +29 -0
- plainfile_project_check-0.1.0/setup.cfg +4 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check/__init__.py +6 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check/__main__.py +3 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check/cli.py +64 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check/core.py +173 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check/csv_adapter.py +49 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check/py.typed +1 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/PKG-INFO +144 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/SOURCES.txt +24 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/dependency_links.txt +1 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/entry_points.txt +2 -0
- plainfile_project_check-0.1.0/src/plainfile_project_check.egg-info/top_level.txt +1 -0
- 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,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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
plainfile_project_check
|
|
@@ -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()
|