openpub 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
openpub/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from openpub.registry import claim
2
+
3
+ __all__ = ["claim"]
openpub/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from openpub.cli import cli
2
+
3
+ cli()
openpub/cli.py ADDED
@@ -0,0 +1,28 @@
1
+ import click
2
+
3
+ from openpub.init_cmd import run_init
4
+ from openpub.verify_cmd import run_verify
5
+
6
+
7
+ @click.group()
8
+ def cli():
9
+ """openpub — verify scientific paper claims with code."""
10
+ pass
11
+
12
+
13
+ @cli.command()
14
+ @click.argument("claims_json", type=click.Path(exists=True))
15
+ @click.option("-o", "--output", default=".", help="Output directory for scaffolded project.")
16
+ def init(claims_json, output):
17
+ """Scaffold a paper verification project from a claims JSON file."""
18
+ run_init(claims_json, output)
19
+ click.echo(f"Scaffolded project in {output}")
20
+
21
+
22
+ @cli.command()
23
+ @click.option("--claims", default="./claims.json", help="Path to claims JSON file.")
24
+ @click.option("--dir", "directory", default=".", help="Directory to scan for .py files.")
25
+ def verify(claims, directory):
26
+ """Run @claim-decorated functions and compare results against expected values."""
27
+ exit_code = run_verify(claims, directory)
28
+ raise SystemExit(exit_code)
openpub/comparison.py ADDED
@@ -0,0 +1,61 @@
1
+ from typing import Any
2
+
3
+
4
+ def compare_values(expected: Any, actual: Any, path: str = "") -> list[str]:
5
+ """Compare expected and actual values, returning a list of failure messages.
6
+
7
+ Handles:
8
+ - {"value": X, "tolerance": Y} dicts -> tolerance-based numeric comparison
9
+ - Plain int -> exact match (type-strict)
10
+ - Plain float -> near-exact (relative epsilon 1e-9)
11
+ - str -> exact match
12
+ - Nested dict -> recursive comparison
13
+ - Missing keys -> failure; extra keys -> informational, not failure
14
+ """
15
+ failures = []
16
+
17
+ if isinstance(expected, dict) and "value" in expected and "tolerance" in expected:
18
+ # Tolerance-based comparison
19
+ if not isinstance(actual, (int, float)):
20
+ failures.append(f"{path}: expected numeric value, got {type(actual).__name__}")
21
+ return failures
22
+ exp_val = expected["value"]
23
+ tol = expected["tolerance"]
24
+ if abs(actual - exp_val) > tol:
25
+ failures.append(
26
+ f"{path}: {actual} != {exp_val} (tolerance {tol}, diff {abs(actual - exp_val):.6g})"
27
+ )
28
+ elif isinstance(expected, dict):
29
+ # Nested dict -> recursive comparison
30
+ if not isinstance(actual, dict):
31
+ failures.append(f"{path}: expected dict, got {type(actual).__name__}")
32
+ return failures
33
+ for key in expected:
34
+ child_path = f"{path}.{key}" if path else key
35
+ if key not in actual:
36
+ failures.append(f"{child_path}: missing key")
37
+ else:
38
+ failures.extend(compare_values(expected[key], actual[key], child_path))
39
+ elif isinstance(expected, int) and not isinstance(expected, bool):
40
+ # Exact integer match (type-strict)
41
+ if not isinstance(actual, int) or isinstance(actual, bool):
42
+ failures.append(f"{path}: expected int, got {type(actual).__name__} ({actual!r})")
43
+ elif actual != expected:
44
+ failures.append(f"{path}: {actual} != {expected}")
45
+ elif isinstance(expected, float):
46
+ # Near-exact float match
47
+ if not isinstance(actual, (int, float)):
48
+ failures.append(f"{path}: expected numeric, got {type(actual).__name__}")
49
+ elif expected == 0.0:
50
+ if abs(actual) > 1e-9:
51
+ failures.append(f"{path}: {actual} != {expected}")
52
+ elif abs(actual - expected) / max(abs(expected), 1e-15) > 1e-9:
53
+ failures.append(f"{path}: {actual} != {expected}")
54
+ elif isinstance(expected, str):
55
+ if actual != expected:
56
+ failures.append(f"{path}: {actual!r} != {expected!r}")
57
+ else:
58
+ if actual != expected:
59
+ failures.append(f"{path}: {actual!r} != {expected!r}")
60
+
61
+ return failures
openpub/init_cmd.py ADDED
@@ -0,0 +1,136 @@
1
+ import json
2
+ import shutil
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+
7
+ def _make_function_name(claim_id: str) -> str:
8
+ """Convert claim ID like 'C5' to function name like 'verify_c5'."""
9
+ return f"verify_{claim_id.lower()}"
10
+
11
+
12
+ def _format_return_value(expected: dict[str, Any], indent: int = 1) -> str:
13
+ """Format expected values as a Python return dict literal."""
14
+ lines = []
15
+ prefix = " " * indent
16
+ lines.append("{")
17
+ items = list(expected.items())
18
+ for i, (key, val) in enumerate(items):
19
+ comma = "," if i < len(items) - 1 else ","
20
+ if isinstance(val, dict) and "value" in val and "tolerance" in val:
21
+ # Tolerance value: emit the scalar with a tolerance comment
22
+ lines.append(f'{prefix} "{key}": {_format_scalar(val["value"])},{comma[:-1]} # tolerance: {val["tolerance"]}')
23
+ elif isinstance(val, dict):
24
+ # Nested dict
25
+ nested = _format_return_value(val, indent + 1)
26
+ lines.append(f'{prefix} "{key}": {nested}{comma}')
27
+ else:
28
+ lines.append(f'{prefix} "{key}": {_format_scalar(val)}{comma}')
29
+ lines.append(f"{prefix}}}")
30
+ return "\n".join(lines)
31
+
32
+
33
+ def _format_scalar(val: Any) -> str:
34
+ """Format a scalar value as a Python literal."""
35
+ if isinstance(val, str):
36
+ return repr(val)
37
+ if isinstance(val, bool):
38
+ return repr(val)
39
+ if isinstance(val, int):
40
+ return str(val)
41
+ if isinstance(val, float):
42
+ return repr(val)
43
+ return repr(val)
44
+
45
+
46
+ def generate_analysis_py(claims: list[dict]) -> str:
47
+ """Generate analysis.py content with stub functions for claims with expected values."""
48
+ lines = ['from openpub import claim', '', '']
49
+
50
+ claims_with_expected = [c for c in claims if "expected" in c]
51
+
52
+ for i, c in enumerate(claims_with_expected):
53
+ claim_id = c["claim_id"]
54
+ fn_name = _make_function_name(claim_id)
55
+ expected = c["expected"]
56
+
57
+ lines.append(f'@claim("{claim_id}")')
58
+ lines.append(f'def {fn_name}():')
59
+ lines.append(f' """Verify: {c["claim"][:80]}"""')
60
+
61
+ return_dict = _format_return_value(expected)
62
+ lines.append(f' return {return_dict}')
63
+
64
+ if i < len(claims_with_expected) - 1:
65
+ lines.append('')
66
+ lines.append('')
67
+
68
+ lines.append('')
69
+ return "\n".join(lines)
70
+
71
+
72
+ def generate_pyproject_toml() -> str:
73
+ """Generate a minimal pyproject.toml for the scaffolded project."""
74
+ return '''[build-system]
75
+ requires = ["hatchling"]
76
+ build-backend = "hatchling.build"
77
+
78
+ [project]
79
+ name = "my-paper"
80
+ version = "0.1.0"
81
+ requires-python = ">=3.10"
82
+ dependencies = [
83
+ "openpub",
84
+ ]
85
+ '''
86
+
87
+
88
+ def generate_readme(claims: list[dict]) -> str:
89
+ """Generate a README.md for the scaffolded project."""
90
+ total = len(claims)
91
+ with_expected = sum(1 for c in claims if "expected" in c)
92
+ return f"""# Paper Verification
93
+
94
+ This project uses [openpub](https://pypi.org/project/openpub/) to verify scientific claims.
95
+
96
+ - **Total claims**: {total}
97
+ - **Verifiable claims** (with expected values): {with_expected}
98
+
99
+ ## Quick Start
100
+
101
+ ```bash
102
+ # Install dependencies
103
+ uv pip install -e .
104
+
105
+ # Run verification
106
+ openpub verify
107
+ ```
108
+
109
+ ## Files
110
+
111
+ - `claims.json` — Structured claims extracted from the paper
112
+ - `analysis.py` — Verification functions decorated with `@claim`
113
+ """
114
+
115
+
116
+ def run_init(claims_path: str, output_dir: str) -> None:
117
+ """Scaffold a paper verification project from a claims JSON file."""
118
+ claims_file = Path(claims_path)
119
+ if not claims_file.exists():
120
+ raise FileNotFoundError(f"Claims file not found: {claims_path}")
121
+
122
+ claims = json.loads(claims_file.read_text())
123
+ out = Path(output_dir)
124
+ out.mkdir(parents=True, exist_ok=True)
125
+
126
+ # analysis.py
127
+ (out / "analysis.py").write_text(generate_analysis_py(claims))
128
+
129
+ # pyproject.toml
130
+ (out / "pyproject.toml").write_text(generate_pyproject_toml())
131
+
132
+ # README.md
133
+ (out / "README.md").write_text(generate_readme(claims))
134
+
135
+ # claims.json (copy verbatim)
136
+ shutil.copy2(claims_file, out / "claims.json")
openpub/registry.py ADDED
@@ -0,0 +1,33 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ _registry: dict[str, Callable[[], Any]] = {}
5
+
6
+
7
+ def claim(claim_id: str) -> Callable:
8
+ """Decorator that registers a function as the verifier for a claim ID.
9
+
10
+ Usage:
11
+ @claim("C5")
12
+ def verify_c5():
13
+ return {"n_with_recurrent_variant": 89, ...}
14
+ """
15
+ def decorator(fn: Callable[[], Any]) -> Callable[[], Any]:
16
+ if claim_id in _registry:
17
+ raise ValueError(
18
+ f"Duplicate claim ID {claim_id!r}: "
19
+ f"already registered to {_registry[claim_id].__name__!r}"
20
+ )
21
+ _registry[claim_id] = fn
22
+ return fn
23
+ return decorator
24
+
25
+
26
+ def get_registry() -> dict[str, Callable[[], Any]]:
27
+ """Return a copy of the current claim registry."""
28
+ return dict(_registry)
29
+
30
+
31
+ def clear_registry() -> None:
32
+ """Clear all registered claims. Used for testing."""
33
+ _registry.clear()
openpub/verify_cmd.py ADDED
@@ -0,0 +1,143 @@
1
+ import importlib.util
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import click
7
+
8
+ from openpub.comparison import compare_values
9
+ from openpub.registry import clear_registry, get_registry
10
+
11
+
12
+ def _discover_modules(directory: Path) -> list[Path]:
13
+ """Find all .py files in the directory, excluding _-prefixed files."""
14
+ return sorted(
15
+ p for p in directory.glob("*.py")
16
+ if not p.name.startswith("_")
17
+ )
18
+
19
+
20
+ def _import_module_from_path(path: Path) -> None:
21
+ """Import a Python module from a file path, triggering @claim registrations."""
22
+ module_name = path.stem
23
+ spec = importlib.util.spec_from_file_location(module_name, path)
24
+ if spec is None or spec.loader is None:
25
+ return
26
+ module = importlib.util.module_from_spec(spec)
27
+ sys.modules[module_name] = module
28
+ spec.loader.exec_module(module)
29
+
30
+
31
+ def run_verify(claims_path: str, directory: str) -> int:
32
+ """Run verification of claims. Returns exit code (0 = success, 1 = failures)."""
33
+ claims_file = Path(claims_path)
34
+ if not claims_file.exists():
35
+ click.secho(f"Error: claims file not found: {claims_path}", fg="red")
36
+ return 1
37
+
38
+ claims = json.loads(claims_file.read_text())
39
+ claims_with_expected = {c["claim_id"]: c for c in claims if "expected" in c}
40
+
41
+ # Clear registry and discover modules
42
+ clear_registry()
43
+ cwd = Path(directory)
44
+ for py_file in _discover_modules(cwd):
45
+ try:
46
+ _import_module_from_path(py_file)
47
+ except Exception as e:
48
+ click.secho(f"Warning: failed to import {py_file.name}: {e}", fg="yellow")
49
+
50
+ registry = get_registry()
51
+
52
+ verified = []
53
+ failed = []
54
+ errors = []
55
+ open_claims = []
56
+
57
+ for claim_id, claim_data in sorted(claims_with_expected.items(), key=lambda x: _sort_key(x[0])):
58
+ expected = claim_data["expected"]
59
+
60
+ if claim_id not in registry:
61
+ open_claims.append(claim_id)
62
+ continue
63
+
64
+ fn = registry[claim_id]
65
+ try:
66
+ result = fn()
67
+ except Exception as e:
68
+ errors.append((claim_id, str(e)))
69
+ continue
70
+
71
+ if not isinstance(result, dict):
72
+ errors.append((claim_id, f"returned {type(result).__name__}, expected dict"))
73
+ continue
74
+
75
+ failures = compare_values(expected, result)
76
+ if failures:
77
+ failed.append((claim_id, failures))
78
+ else:
79
+ verified.append(claim_id)
80
+
81
+ # Report results
82
+ _print_results(verified, failed, errors, open_claims, len(claims_with_expected))
83
+
84
+ if failed or errors:
85
+ return 1
86
+ return 0
87
+
88
+
89
+ def _sort_key(claim_id: str) -> tuple[str, int]:
90
+ """Sort claim IDs like C1, C2, ..., C10 numerically."""
91
+ prefix = ""
92
+ num = 0
93
+ for i, ch in enumerate(claim_id):
94
+ if ch.isdigit():
95
+ prefix = claim_id[:i]
96
+ num = int(claim_id[i:])
97
+ break
98
+ return (prefix, num)
99
+
100
+
101
+ def _print_results(
102
+ verified: list[str],
103
+ failed: list[tuple[str, list[str]]],
104
+ errors: list[tuple[str, str]],
105
+ open_claims: list[str],
106
+ total: int,
107
+ ) -> None:
108
+ """Print colored verification results."""
109
+ click.echo()
110
+
111
+ if verified:
112
+ click.secho(f" VERIFIED ({len(verified)})", fg="green", bold=True)
113
+ for cid in verified:
114
+ click.secho(f" {cid}", fg="green")
115
+
116
+ if failed:
117
+ click.echo()
118
+ click.secho(f" FAILED ({len(failed)})", fg="red", bold=True)
119
+ for cid, failures in failed:
120
+ click.secho(f" {cid}:", fg="red")
121
+ for f in failures:
122
+ click.secho(f" {f}", fg="red")
123
+
124
+ if errors:
125
+ click.echo()
126
+ click.secho(f" ERRORS ({len(errors)})", fg="yellow", bold=True)
127
+ for cid, msg in errors:
128
+ click.secho(f" {cid}: {msg}", fg="yellow")
129
+
130
+ if open_claims:
131
+ click.echo()
132
+ click.secho(f" OPEN ({len(open_claims)})", fg="cyan", bold=True)
133
+ for cid in open_claims:
134
+ click.secho(f" {cid}", fg="cyan")
135
+
136
+ click.echo()
137
+ click.echo(
138
+ f" {len(verified)}/{total} verified, "
139
+ f"{len(failed)} failed, "
140
+ f"{len(errors)} errors, "
141
+ f"{len(open_claims)} open"
142
+ )
143
+ click.echo()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: openpub
3
+ Version: 0.1.0
4
+ Summary: Verify scientific paper claims with code
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: click>=8.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0; extra == 'dev'
@@ -0,0 +1,11 @@
1
+ openpub/__init__.py,sha256=b2lq9sf5VH4QjyuOL1Nkiv1EiDIl5kmMGUJpPY-ZOas,56
2
+ openpub/__main__.py,sha256=qTfPt9xr4yBzISFsza4kta6SsFeNqYdB9oUnfWVTp8U,35
3
+ openpub/cli.py,sha256=AsOMS2lgiK5PvYda4OxEadNJt7lP6O76RcWRHPv11ok,942
4
+ openpub/comparison.py,sha256=Fw-Tlkse3qnj74rkHGbD9KZejSt0nj159xfnJaOkN8s,2726
5
+ openpub/init_cmd.py,sha256=bUfV99mEutAJBJzZZzKu8rRWJzJMXRQtJDuVvibPbOA,4070
6
+ openpub/registry.py,sha256=sJcweF58ao2qB8He2xc0hc_IwS9YBTJozAP6uxtZjtw,931
7
+ openpub/verify_cmd.py,sha256=IuipKvHjRicux3KCLhEl-HYrLgxHhYtEiem4X53vkog,4208
8
+ openpub-0.1.0.dist-info/METADATA,sha256=kXkEb8KRMRFhsETeocKOKPrRArAqArfCDnkfHgrQg3I,214
9
+ openpub-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ openpub-0.1.0.dist-info/entry_points.txt,sha256=q4kJbzVf4TwBMjAVlaHXBryRnnlf_FhUF9qKlT-kEoY,44
11
+ openpub-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openpub = openpub.cli:cli