oddrun 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.
- oddrun-0.1.0/LICENSE +20 -0
- oddrun-0.1.0/PKG-INFO +136 -0
- oddrun-0.1.0/README.md +106 -0
- oddrun-0.1.0/pyproject.toml +75 -0
- oddrun-0.1.0/setup.cfg +4 -0
- oddrun-0.1.0/src/oddrun/__init__.py +34 -0
- oddrun-0.1.0/src/oddrun/cli.py +325 -0
- oddrun-0.1.0/src/oddrun/compare.py +93 -0
- oddrun-0.1.0/src/oddrun/diagnose.py +374 -0
- oddrun-0.1.0/src/oddrun/execution.py +414 -0
- oddrun-0.1.0/src/oddrun/experiments.py +257 -0
- oddrun-0.1.0/src/oddrun/io.py +138 -0
- oddrun-0.1.0/src/oddrun/models.py +144 -0
- oddrun-0.1.0/src/oddrun/security.py +66 -0
- oddrun-0.1.0/src/oddrun/snapshot.py +93 -0
- oddrun-0.1.0/src/oddrun.egg-info/PKG-INFO +136 -0
- oddrun-0.1.0/src/oddrun.egg-info/SOURCES.txt +27 -0
- oddrun-0.1.0/src/oddrun.egg-info/dependency_links.txt +1 -0
- oddrun-0.1.0/src/oddrun.egg-info/entry_points.txt +2 -0
- oddrun-0.1.0/src/oddrun.egg-info/requires.txt +5 -0
- oddrun-0.1.0/src/oddrun.egg-info/top_level.txt +1 -0
- oddrun-0.1.0/tests/test_cli.py +128 -0
- oddrun-0.1.0/tests/test_compare.py +96 -0
- oddrun-0.1.0/tests/test_io.py +77 -0
- oddrun-0.1.0/tests/test_models.py +93 -0
- oddrun-0.1.0/tests/test_scenarios.py +147 -0
- oddrun-0.1.0/tests/test_security.py +81 -0
- oddrun-0.1.0/tests/test_snapshot.py +41 -0
- oddrun-0.1.0/tests/test_why.py +296 -0
oddrun-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OddRun 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 MECHANICAL FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
|
18
|
+
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
|
19
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR
|
|
20
|
+
THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
oddrun-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oddrun
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Investigates why the same Python program behaves differently across environments.
|
|
5
|
+
Author: OddRun Contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/oddrun/oddrun
|
|
8
|
+
Project-URL: Documentation, https://github.com/oddrun/oddrun#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/oddrun/oddrun
|
|
10
|
+
Project-URL: Issues, https://github.com/oddrun/oddrun/issues
|
|
11
|
+
Keywords: debugging,environment,reproducibility,diff,snapshot,developer-tools
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
21
|
+
Classifier: Topic :: Software Development :: Testing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
28
|
+
Requires-Dist: build>=1.0.0; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# OddRun
|
|
32
|
+
|
|
33
|
+
> When the same code doesn't behave the same.
|
|
34
|
+
|
|
35
|
+
OddRun is a Python developer tool that investigates why the same program behaves differently across environments.
|
|
36
|
+
|
|
37
|
+
Traditional environment tools answer:
|
|
38
|
+
> *"What is different between these environments?"*
|
|
39
|
+
|
|
40
|
+
OddRun answers:
|
|
41
|
+
> *"Which difference experimentally explains the observed failure?"*
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Core Product Model
|
|
46
|
+
|
|
47
|
+
| Subcommand | Question | Input Artifact |
|
|
48
|
+
| :--- | :--- | :--- |
|
|
49
|
+
| `oddrun capture` | **What's different?** Captures environment state | `EnvironmentSnapshot` JSON |
|
|
50
|
+
| `oddrun compare` | **What changed?** Compares two environment snapshots | Two `EnvironmentSnapshot` JSONs |
|
|
51
|
+
| `oddrun record` | **What actually happened here?** Executes command 1x on target | `ExecutionRecord` JSON |
|
|
52
|
+
| `oddrun why` | **Which difference explains the target behavior?** | `ExecutionRecord` JSON + `<command>` |
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Quickstart
|
|
57
|
+
|
|
58
|
+
### Installation
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install oddrun
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### The Distributed Workflow (`record` → `why`)
|
|
65
|
+
|
|
66
|
+
#### 1. On Target Environment (e.g. CI / Remote Server where code fails)
|
|
67
|
+
Execute the failing command once to record the environment state and observed failure signature:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
oddrun record -o target-run.json -- pytest tests/test_date.py
|
|
71
|
+
```
|
|
72
|
+
*(Generates `target-run.json` containing safe environment metadata and the observed failure `BehaviorSignature`)*
|
|
73
|
+
|
|
74
|
+
#### 2. On Developer Machine (Baseline environment where code passes)
|
|
75
|
+
Diagnose which environmental difference reproduces the target failure:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
oddrun why target-run.json -- pytest tests/test_date.py
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
OddRun will:
|
|
82
|
+
1. Verify baseline stability (3/3 identical passing runs in current environment).
|
|
83
|
+
2. Rank environmental differences by safety tier (`TZ`, `LANG`, `LC_*`, application variables).
|
|
84
|
+
3. Execute controlled perturbations (1x exploratory + 2x confirmation).
|
|
85
|
+
4. Match structural behavior signatures (`exit_code`, `exception_type`, normalized diagnostic output).
|
|
86
|
+
5. Report **Experimentally Supported Causal Factors** with remediation recommendations.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Direct Environment Comparison (`capture` → `compare`)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# Capture local environment snapshot
|
|
94
|
+
oddrun capture -o local.json
|
|
95
|
+
|
|
96
|
+
# Compare local environment against target snapshot
|
|
97
|
+
oddrun compare local.json target-run.json
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Python API Usage
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from oddrun import (
|
|
106
|
+
capture_environment,
|
|
107
|
+
compare_snapshots,
|
|
108
|
+
record_execution,
|
|
109
|
+
diagnose_behavior,
|
|
110
|
+
load_record,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# 1. Record execution
|
|
114
|
+
record = record_execution(["pytest", "tests/test_date.py"])
|
|
115
|
+
print(f"Recorded outcome: {record.execution_result.behavior_signature.is_success}")
|
|
116
|
+
|
|
117
|
+
# 2. Diagnose behavior against target record
|
|
118
|
+
report = diagnose_behavior("target-run.json", ["pytest", "tests/test_date.py"])
|
|
119
|
+
for factor in report.causal_factors:
|
|
120
|
+
print(f"Causal Factor: {factor.candidate.key} ({factor.evidence_level.value})")
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Security & Privacy
|
|
126
|
+
|
|
127
|
+
OddRun is strictly **local-first**:
|
|
128
|
+
- Zero telemetry or analytics.
|
|
129
|
+
- Zero network calls or cloud dependencies; operates 100% offline.
|
|
130
|
+
- **Automatic Secret Redaction**: Sensitive environment keys (`API_KEY`, `SECRET`, `PASSWORD`, `TOKEN`, `CREDENTIAL`, `AUTH`, etc.) are automatically replaced with `"<present>"` before saving snapshots or execution records to disk.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
[MIT License](LICENSE)
|
oddrun-0.1.0/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# OddRun
|
|
2
|
+
|
|
3
|
+
> When the same code doesn't behave the same.
|
|
4
|
+
|
|
5
|
+
OddRun is a Python developer tool that investigates why the same program behaves differently across environments.
|
|
6
|
+
|
|
7
|
+
Traditional environment tools answer:
|
|
8
|
+
> *"What is different between these environments?"*
|
|
9
|
+
|
|
10
|
+
OddRun answers:
|
|
11
|
+
> *"Which difference experimentally explains the observed failure?"*
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Core Product Model
|
|
16
|
+
|
|
17
|
+
| Subcommand | Question | Input Artifact |
|
|
18
|
+
| :--- | :--- | :--- |
|
|
19
|
+
| `oddrun capture` | **What's different?** Captures environment state | `EnvironmentSnapshot` JSON |
|
|
20
|
+
| `oddrun compare` | **What changed?** Compares two environment snapshots | Two `EnvironmentSnapshot` JSONs |
|
|
21
|
+
| `oddrun record` | **What actually happened here?** Executes command 1x on target | `ExecutionRecord` JSON |
|
|
22
|
+
| `oddrun why` | **Which difference explains the target behavior?** | `ExecutionRecord` JSON + `<command>` |
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Quickstart
|
|
27
|
+
|
|
28
|
+
### Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install oddrun
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### The Distributed Workflow (`record` → `why`)
|
|
35
|
+
|
|
36
|
+
#### 1. On Target Environment (e.g. CI / Remote Server where code fails)
|
|
37
|
+
Execute the failing command once to record the environment state and observed failure signature:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
oddrun record -o target-run.json -- pytest tests/test_date.py
|
|
41
|
+
```
|
|
42
|
+
*(Generates `target-run.json` containing safe environment metadata and the observed failure `BehaviorSignature`)*
|
|
43
|
+
|
|
44
|
+
#### 2. On Developer Machine (Baseline environment where code passes)
|
|
45
|
+
Diagnose which environmental difference reproduces the target failure:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
oddrun why target-run.json -- pytest tests/test_date.py
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
OddRun will:
|
|
52
|
+
1. Verify baseline stability (3/3 identical passing runs in current environment).
|
|
53
|
+
2. Rank environmental differences by safety tier (`TZ`, `LANG`, `LC_*`, application variables).
|
|
54
|
+
3. Execute controlled perturbations (1x exploratory + 2x confirmation).
|
|
55
|
+
4. Match structural behavior signatures (`exit_code`, `exception_type`, normalized diagnostic output).
|
|
56
|
+
5. Report **Experimentally Supported Causal Factors** with remediation recommendations.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Direct Environment Comparison (`capture` → `compare`)
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Capture local environment snapshot
|
|
64
|
+
oddrun capture -o local.json
|
|
65
|
+
|
|
66
|
+
# Compare local environment against target snapshot
|
|
67
|
+
oddrun compare local.json target-run.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## Python API Usage
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from oddrun import (
|
|
76
|
+
capture_environment,
|
|
77
|
+
compare_snapshots,
|
|
78
|
+
record_execution,
|
|
79
|
+
diagnose_behavior,
|
|
80
|
+
load_record,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# 1. Record execution
|
|
84
|
+
record = record_execution(["pytest", "tests/test_date.py"])
|
|
85
|
+
print(f"Recorded outcome: {record.execution_result.behavior_signature.is_success}")
|
|
86
|
+
|
|
87
|
+
# 2. Diagnose behavior against target record
|
|
88
|
+
report = diagnose_behavior("target-run.json", ["pytest", "tests/test_date.py"])
|
|
89
|
+
for factor in report.causal_factors:
|
|
90
|
+
print(f"Causal Factor: {factor.candidate.key} ({factor.evidence_level.value})")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Security & Privacy
|
|
96
|
+
|
|
97
|
+
OddRun is strictly **local-first**:
|
|
98
|
+
- Zero telemetry or analytics.
|
|
99
|
+
- Zero network calls or cloud dependencies; operates 100% offline.
|
|
100
|
+
- **Automatic Secret Redaction**: Sensitive environment keys (`API_KEY`, `SECRET`, `PASSWORD`, `TOKEN`, `CREDENTIAL`, `AUTH`, etc.) are automatically replaced with `"<present>"` before saving snapshots or execution records to disk.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## License
|
|
105
|
+
|
|
106
|
+
[MIT License](LICENSE)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oddrun"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Investigates why the same Python program behaves differently across environments."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "OddRun Contributors" }
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"debugging",
|
|
17
|
+
"environment",
|
|
18
|
+
"reproducibility",
|
|
19
|
+
"diff",
|
|
20
|
+
"snapshot",
|
|
21
|
+
"developer-tools"
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 3 - Alpha",
|
|
25
|
+
"Intended Audience :: Developers",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: 3.14",
|
|
32
|
+
"Topic :: Software Development :: Debuggers",
|
|
33
|
+
"Topic :: Software Development :: Testing",
|
|
34
|
+
]
|
|
35
|
+
dependencies = []
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=7.0.0",
|
|
40
|
+
"ruff>=0.1.0",
|
|
41
|
+
"build>=1.0.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
oddrun = "oddrun.cli:main"
|
|
46
|
+
|
|
47
|
+
[project.urls]
|
|
48
|
+
Homepage = "https://github.com/oddrun/oddrun"
|
|
49
|
+
Documentation = "https://github.com/oddrun/oddrun#readme"
|
|
50
|
+
Repository = "https://github.com/oddrun/oddrun"
|
|
51
|
+
Issues = "https://github.com/oddrun/oddrun/issues"
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.packages.find]
|
|
54
|
+
where = ["src"]
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 88
|
|
58
|
+
target-version = "py310"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = [
|
|
62
|
+
"E", # pycodestyle errors
|
|
63
|
+
"W", # pycodestyle warnings
|
|
64
|
+
"F", # pyflakes
|
|
65
|
+
"I", # isort
|
|
66
|
+
"B", # flake8-bugbear
|
|
67
|
+
"C4", # flake8-comprehensions
|
|
68
|
+
"UP", # pyupgrade
|
|
69
|
+
]
|
|
70
|
+
ignore = []
|
|
71
|
+
|
|
72
|
+
[tool.pytest.ini_options]
|
|
73
|
+
testpaths = ["tests"]
|
|
74
|
+
python_files = ["test_*.py"]
|
|
75
|
+
addopts = "-v"
|
oddrun-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""OddRun - When the same code doesn't behave the same."""
|
|
2
|
+
|
|
3
|
+
from oddrun.compare import compare_snapshots
|
|
4
|
+
from oddrun.diagnose import diagnose_behavior
|
|
5
|
+
from oddrun.execution import ExecutionRecord, record_execution
|
|
6
|
+
from oddrun.io import load_record, load_snapshot, save_record, save_snapshot
|
|
7
|
+
from oddrun.models import (
|
|
8
|
+
DiffItem,
|
|
9
|
+
EnvironmentSnapshot,
|
|
10
|
+
PythonInfo,
|
|
11
|
+
SnapshotDiff,
|
|
12
|
+
SystemInfo,
|
|
13
|
+
)
|
|
14
|
+
from oddrun.snapshot import capture_environment
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"EnvironmentSnapshot",
|
|
21
|
+
"ExecutionRecord",
|
|
22
|
+
"PythonInfo",
|
|
23
|
+
"SystemInfo",
|
|
24
|
+
"SnapshotDiff",
|
|
25
|
+
"DiffItem",
|
|
26
|
+
"capture_environment",
|
|
27
|
+
"compare_snapshots",
|
|
28
|
+
"diagnose_behavior",
|
|
29
|
+
"load_record",
|
|
30
|
+
"load_snapshot",
|
|
31
|
+
"record_execution",
|
|
32
|
+
"save_record",
|
|
33
|
+
"save_snapshot",
|
|
34
|
+
]
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Command-line interface for OddRun."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from oddrun import __version__
|
|
11
|
+
from oddrun.compare import compare_snapshots
|
|
12
|
+
from oddrun.diagnose import diagnose_behavior, format_diagnosis_text
|
|
13
|
+
from oddrun.execution import ExecutionStatus, record_execution
|
|
14
|
+
from oddrun.io import SnapshotIOError, load_snapshot, save_record, save_snapshot
|
|
15
|
+
from oddrun.models import SnapshotDiff
|
|
16
|
+
from oddrun.snapshot import capture_environment
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_diff_text(diff: SnapshotDiff) -> str:
|
|
20
|
+
"""Format a SnapshotDiff into readable text lines."""
|
|
21
|
+
if not diff.has_differences:
|
|
22
|
+
return "No differences found between snapshots."
|
|
23
|
+
|
|
24
|
+
lines: list[str] = [
|
|
25
|
+
f"OddRun Environment Comparison: {diff.left_label} vs {diff.right_label}",
|
|
26
|
+
f"Total Differences: {len(diff.differences)}",
|
|
27
|
+
"-" * 60,
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
current_cat = ""
|
|
31
|
+
for item in diff.differences:
|
|
32
|
+
if item.category != current_cat:
|
|
33
|
+
current_cat = item.category
|
|
34
|
+
lines.append(f"\n[{current_cat.upper()}]")
|
|
35
|
+
|
|
36
|
+
if item.change_type == "added":
|
|
37
|
+
lines.append(f" + {item.key}: {item.right_value}")
|
|
38
|
+
elif item.change_type == "removed":
|
|
39
|
+
lines.append(f" - {item.key}: (was: {item.left_value})")
|
|
40
|
+
else:
|
|
41
|
+
lines.append(f" ~ {item.key}: {item.left_value} -> {item.right_value}")
|
|
42
|
+
|
|
43
|
+
return "\n".join(lines)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_capture(args: argparse.Namespace) -> int:
|
|
47
|
+
"""Execute 'oddrun capture' subcommand."""
|
|
48
|
+
snapshot = capture_environment()
|
|
49
|
+
output_path = args.output or "oddrun-snapshot.json"
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
save_snapshot(snapshot, output_path)
|
|
53
|
+
print(f"Successfully captured environment snapshot: {output_path}")
|
|
54
|
+
return 0
|
|
55
|
+
except SnapshotIOError as exc:
|
|
56
|
+
print(f"ERROR: Could not write snapshot: {output_path}", file=sys.stderr)
|
|
57
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
if args.debug:
|
|
61
|
+
raise
|
|
62
|
+
print(f"ERROR: Unexpected failure during capture: {exc}", file=sys.stderr)
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_record(args: argparse.Namespace) -> int:
|
|
67
|
+
"""Execute 'oddrun record' subcommand."""
|
|
68
|
+
command_argv = args.command
|
|
69
|
+
if not command_argv:
|
|
70
|
+
print("ERROR: No command specified for recording.", file=sys.stderr)
|
|
71
|
+
msg = "Usage: oddrun record --output RECORD.json -- COMMAND [ARGUMENTS...]"
|
|
72
|
+
print(msg, file=sys.stderr)
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
output_path = args.output or "oddrun-record.json"
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
record = record_execution(command_argv, timeout_seconds=args.timeout)
|
|
79
|
+
if record.execution_result.status == ExecutionStatus.EXEC_ERROR:
|
|
80
|
+
err_msg = record.execution_result.error_message or "Execution failed"
|
|
81
|
+
print(f"ERROR: Could not execute command: {err_msg}", file=sys.stderr)
|
|
82
|
+
return 1
|
|
83
|
+
|
|
84
|
+
save_record(record, output_path)
|
|
85
|
+
outcome = "PASS" if record.execution_result.exit_code == 0 else "FAIL"
|
|
86
|
+
code = record.execution_result.exit_code
|
|
87
|
+
print(
|
|
88
|
+
f"Successfully recorded execution "
|
|
89
|
+
f"(Outcome: {outcome}, Exit Code: {code}): {output_path}"
|
|
90
|
+
)
|
|
91
|
+
return 0
|
|
92
|
+
except SnapshotIOError as exc:
|
|
93
|
+
print(f"ERROR: Could not write record file: {output_path}", file=sys.stderr)
|
|
94
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
95
|
+
return 1
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
if args.debug:
|
|
98
|
+
raise
|
|
99
|
+
print(f"ERROR: Unexpected failure during recording: {exc}", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_compare(args: argparse.Namespace) -> int:
|
|
104
|
+
"""Execute 'oddrun compare' subcommand."""
|
|
105
|
+
left_file = args.snapshot1
|
|
106
|
+
right_file = args.snapshot2
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
left_snap = load_snapshot(left_file)
|
|
110
|
+
except SnapshotIOError as exc:
|
|
111
|
+
print(f"ERROR: Could not read snapshot: {left_file}", file=sys.stderr)
|
|
112
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
if args.debug:
|
|
116
|
+
raise
|
|
117
|
+
print(f"ERROR: Failed loading snapshot '{left_file}': {exc}", file=sys.stderr)
|
|
118
|
+
return 1
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
right_snap = load_snapshot(right_file)
|
|
122
|
+
except SnapshotIOError as exc:
|
|
123
|
+
print(f"ERROR: Could not read snapshot: {right_file}", file=sys.stderr)
|
|
124
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
125
|
+
return 1
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
if args.debug:
|
|
128
|
+
raise
|
|
129
|
+
print(f"ERROR: Failed loading snapshot '{right_file}': {exc}", file=sys.stderr)
|
|
130
|
+
return 1
|
|
131
|
+
|
|
132
|
+
diff = compare_snapshots(
|
|
133
|
+
left_snap, right_snap, left_label=str(left_file), right_label=str(right_file)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if args.json:
|
|
137
|
+
print(json.dumps(diff.to_dict(), indent=2))
|
|
138
|
+
else:
|
|
139
|
+
print(format_diff_text(diff))
|
|
140
|
+
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def run_why(args: argparse.Namespace) -> int:
|
|
145
|
+
"""Execute 'oddrun why' subcommand."""
|
|
146
|
+
target_record = args.target_record
|
|
147
|
+
command_argv = args.command
|
|
148
|
+
|
|
149
|
+
if not command_argv:
|
|
150
|
+
print("ERROR: No command specified for diagnosis.", file=sys.stderr)
|
|
151
|
+
msg = "Usage: oddrun why TARGET_RECORD.json -- COMMAND [ARGUMENTS...]"
|
|
152
|
+
print(msg, file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
report = diagnose_behavior(
|
|
157
|
+
target_record=target_record,
|
|
158
|
+
command=command_argv,
|
|
159
|
+
allow_path_perturbation=args.allow_path_perturbation,
|
|
160
|
+
max_experiments=args.max_experiments,
|
|
161
|
+
timeout_seconds=args.timeout,
|
|
162
|
+
)
|
|
163
|
+
print(format_diagnosis_text(report))
|
|
164
|
+
return 0 if not report.error_message else 1
|
|
165
|
+
except SnapshotIOError as exc:
|
|
166
|
+
print(f"ERROR: Could not read target record: {target_record}", file=sys.stderr)
|
|
167
|
+
print(f"Reason: {exc}", file=sys.stderr)
|
|
168
|
+
return 1
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
if args.debug:
|
|
171
|
+
raise
|
|
172
|
+
print(f"ERROR: Causal diagnosis failed: {exc}", file=sys.stderr)
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
177
|
+
"""Construct the main argument parser for OddRun CLI."""
|
|
178
|
+
parser = argparse.ArgumentParser(
|
|
179
|
+
prog="oddrun",
|
|
180
|
+
description="OddRun - When the same code doesn't behave the same.",
|
|
181
|
+
)
|
|
182
|
+
parser.add_argument(
|
|
183
|
+
"--version",
|
|
184
|
+
action="version",
|
|
185
|
+
version=f"%(prog)s {__version__}",
|
|
186
|
+
)
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--debug",
|
|
189
|
+
action="store_true",
|
|
190
|
+
help="Enable full exception tracebacks on error.",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
subparsers = parser.add_subparsers(dest="subcommand", help="Subcommands")
|
|
194
|
+
|
|
195
|
+
# Command: capture
|
|
196
|
+
capture_parser = subparsers.add_parser(
|
|
197
|
+
"capture",
|
|
198
|
+
help="Capture a privacy-aware snapshot of the current environment.",
|
|
199
|
+
)
|
|
200
|
+
capture_parser.add_argument(
|
|
201
|
+
"-o",
|
|
202
|
+
"--output",
|
|
203
|
+
default="oddrun-snapshot.json",
|
|
204
|
+
help="Path to output JSON file (default: oddrun-snapshot.json).",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Command: record
|
|
208
|
+
record_parser = subparsers.add_parser(
|
|
209
|
+
"record",
|
|
210
|
+
help="Execute a command and record environment snapshot + behavior signature.",
|
|
211
|
+
)
|
|
212
|
+
record_parser.add_argument(
|
|
213
|
+
"-o",
|
|
214
|
+
"--output",
|
|
215
|
+
default="oddrun-record.json",
|
|
216
|
+
help="Path to output JSON record file (default: oddrun-record.json).",
|
|
217
|
+
)
|
|
218
|
+
record_parser.add_argument(
|
|
219
|
+
"--timeout",
|
|
220
|
+
type=float,
|
|
221
|
+
default=30.0,
|
|
222
|
+
help="Timeout in seconds for recording command execution (default: 30.0).",
|
|
223
|
+
)
|
|
224
|
+
record_parser.add_argument(
|
|
225
|
+
"command",
|
|
226
|
+
nargs=argparse.REMAINDER,
|
|
227
|
+
help="Command and arguments to execute after '--' separator.",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# Command: compare
|
|
231
|
+
compare_parser = subparsers.add_parser(
|
|
232
|
+
"compare",
|
|
233
|
+
help="Compare two environment snapshot files.",
|
|
234
|
+
)
|
|
235
|
+
compare_parser.add_argument(
|
|
236
|
+
"snapshot1",
|
|
237
|
+
help="Path to first snapshot JSON file.",
|
|
238
|
+
)
|
|
239
|
+
compare_parser.add_argument(
|
|
240
|
+
"snapshot2",
|
|
241
|
+
help="Path to second snapshot JSON file.",
|
|
242
|
+
)
|
|
243
|
+
compare_parser.add_argument(
|
|
244
|
+
"--json",
|
|
245
|
+
action="store_true",
|
|
246
|
+
help="Output comparison results in structured JSON format.",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Command: why
|
|
250
|
+
why_parser = subparsers.add_parser(
|
|
251
|
+
"why",
|
|
252
|
+
help="Diagnose which environmental difference causes a program to fail.",
|
|
253
|
+
)
|
|
254
|
+
why_parser.add_argument(
|
|
255
|
+
"target_record",
|
|
256
|
+
help="Path to target ExecutionRecord JSON file.",
|
|
257
|
+
)
|
|
258
|
+
why_parser.add_argument(
|
|
259
|
+
"--allow-path-perturbation",
|
|
260
|
+
action="store_true",
|
|
261
|
+
help=(
|
|
262
|
+
"Allow testing Tier-2 executable/import factors "
|
|
263
|
+
"(PATH, PYTHONPATH, PYTHONHOME)."
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
why_parser.add_argument(
|
|
267
|
+
"--max-experiments",
|
|
268
|
+
type=int,
|
|
269
|
+
default=10,
|
|
270
|
+
help="Maximum candidate perturbation experiments to test (default: 10).",
|
|
271
|
+
)
|
|
272
|
+
why_parser.add_argument(
|
|
273
|
+
"--timeout",
|
|
274
|
+
type=float,
|
|
275
|
+
default=30.0,
|
|
276
|
+
help="Timeout in seconds per perturbation experiment (default: 30.0).",
|
|
277
|
+
)
|
|
278
|
+
why_parser.add_argument(
|
|
279
|
+
"command",
|
|
280
|
+
nargs=argparse.REMAINDER,
|
|
281
|
+
help="Command and arguments to execute after '--' separator.",
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return parser
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
288
|
+
"""CLI entry point."""
|
|
289
|
+
parser = build_parser()
|
|
290
|
+
|
|
291
|
+
raw_argv = list(argv) if argv is not None else sys.argv[1:]
|
|
292
|
+
command_argv: list[str] = []
|
|
293
|
+
|
|
294
|
+
if "--" in raw_argv:
|
|
295
|
+
split_idx = raw_argv.index("--")
|
|
296
|
+
oddrun_args = raw_argv[:split_idx]
|
|
297
|
+
command_argv = raw_argv[split_idx + 1 :]
|
|
298
|
+
else:
|
|
299
|
+
oddrun_args = raw_argv
|
|
300
|
+
|
|
301
|
+
args = parser.parse_args(oddrun_args)
|
|
302
|
+
if command_argv:
|
|
303
|
+
args.command = command_argv
|
|
304
|
+
elif hasattr(args, "command") and args.command and args.command[0] == "--":
|
|
305
|
+
args.command = args.command[1:]
|
|
306
|
+
|
|
307
|
+
if not args.subcommand:
|
|
308
|
+
parser.print_help()
|
|
309
|
+
return 0
|
|
310
|
+
|
|
311
|
+
if args.subcommand == "capture":
|
|
312
|
+
return run_capture(args)
|
|
313
|
+
elif args.subcommand == "record":
|
|
314
|
+
return run_record(args)
|
|
315
|
+
elif args.subcommand == "compare":
|
|
316
|
+
return run_compare(args)
|
|
317
|
+
elif args.subcommand == "why":
|
|
318
|
+
return run_why(args)
|
|
319
|
+
|
|
320
|
+
parser.print_help()
|
|
321
|
+
return 0
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__":
|
|
325
|
+
sys.exit(main())
|