softschema 0.2.1__tar.gz → 0.2.2__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.
- {softschema-0.2.1 → softschema-0.2.2}/PKG-INFO +3 -3
- {softschema-0.2.1 → softschema-0.2.2}/README.md +2 -2
- {softschema-0.2.1 → softschema-0.2.2}/docs/installation.md +4 -4
- {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-spec.md +12 -0
- softschema-0.2.2/packages/python/README.md +48 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/cli.py +51 -19
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/errors.py +51 -1
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/models.py +1 -1
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/validate.py +1 -1
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_cli.py +39 -0
- softschema-0.2.2/packages/python/tests/test_errors.py +88 -0
- {softschema-0.2.1 → softschema-0.2.2}/pyproject.toml +9 -6
- {softschema-0.2.1 → softschema-0.2.2}/tests/golden/README.md +21 -15
- softschema-0.2.1/packages/python/README.md +0 -31
- softschema-0.2.1/packages/python/tests/test_errors.py +0 -51
- {softschema-0.2.1 → softschema-0.2.2}/.gitignore +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/AGENTS.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/LICENSE +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/docs/development.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/docs/e2e-testing.runbook.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/docs/publishing-npm.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/docs/publishing.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-guide.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-python-design.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-typescript-design.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/__init__.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/README.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/__init__.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/host_integration.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/model.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/movie-page.schema.yaml +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/spirited-away.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/parity/__init__.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/parity/model.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/examples/parity/parity.schema.yaml +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/__init__.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/canonicalize.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/compile.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/generate.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/py.typed +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/registry.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/schema_view.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/soft_field.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_canonicalize.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_core.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_doc_topics_resolve.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_enforced_extras.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_generate.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_metadata_rules.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_movie_page_example.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_parity.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_schema_view.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_skill_mirror_drift.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_soft_field.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_warning_codes.py +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/typescript/LICENSE +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/packages/typescript/README.md +0 -0
- {softschema-0.2.1 → softschema-0.2.2}/skills/softschema/SKILL.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: softschema
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Soft schema conventions and validation tools for Markdown/YAML artifacts
|
|
5
5
|
Project-URL: Homepage, https://github.com/jlevy/softschema
|
|
6
6
|
Project-URL: Repository, https://github.com/jlevy/softschema
|
|
@@ -232,8 +232,8 @@ Two supported ways to consume softschema; pick by use:
|
|
|
232
232
|
fast, offline, and the only way to `import` it):
|
|
233
233
|
|
|
234
234
|
```bash
|
|
235
|
-
uv add --dev softschema==0.2.
|
|
236
|
-
npm install -D softschema@0.2.
|
|
235
|
+
uv add --dev softschema==0.2.2 # Python
|
|
236
|
+
npm install -D softschema@0.2.2 # Node (or: bun add -d)
|
|
237
237
|
```
|
|
238
238
|
|
|
239
239
|
- **Zero-install** for one-off checks and agent bootstrap:
|
|
@@ -204,8 +204,8 @@ Two supported ways to consume softschema; pick by use:
|
|
|
204
204
|
fast, offline, and the only way to `import` it):
|
|
205
205
|
|
|
206
206
|
```bash
|
|
207
|
-
uv add --dev softschema==0.2.
|
|
208
|
-
npm install -D softschema@0.2.
|
|
207
|
+
uv add --dev softschema==0.2.2 # Python
|
|
208
|
+
npm install -D softschema@0.2.2 # Node (or: bun add -d)
|
|
209
209
|
```
|
|
210
210
|
|
|
211
211
|
- **Zero-install** for one-off checks and agent bootstrap:
|
|
@@ -9,7 +9,7 @@ Pick the runtime you already have; both validate against the same canonical sche
|
|
|
9
9
|
| | Pin as a dependency | Zero-install (`uvx` / `npx`) |
|
|
10
10
|
| --- | --- | --- |
|
|
11
11
|
| **For** | Projects, CI gates, library use | One-off checks, agent bootstrap |
|
|
12
|
-
| **Reproducible** | Yes—the version is locked in `uv.lock` / `package-lock.json` | Only if you pin the runner (`uvx softschema@0.2.
|
|
12
|
+
| **Reproducible** | Yes—the version is locked in `uv.lock` / `package-lock.json` | Only if you pin the runner (`uvx softschema@0.2.2`) |
|
|
13
13
|
| **Fast / offline** | Yes—the binary is already on disk | Cold-start fetch; needs the network |
|
|
14
14
|
| **Library import** | Yes—the only way | No |
|
|
15
15
|
|
|
@@ -23,14 +23,14 @@ use a zero-install runner**, pinned where the result must be repeatable.
|
|
|
23
23
|
Python (a dev dependency, or a persistent user tool):
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
uv add --dev softschema==0.2.
|
|
26
|
+
uv add --dev softschema==0.2.2 # project dev dependency; run via `uv run softschema`
|
|
27
27
|
uv tool install softschema # persistent CLI on your PATH
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Node (>= 22.12):
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
|
-
npm install -D softschema@0.2.
|
|
33
|
+
npm install -D softschema@0.2.2 # or: pnpm add -D / bun add -d
|
|
34
34
|
npx softschema --help # resolves the local pinned copy
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -41,7 +41,7 @@ uvx softschema@latest --help # Python implementation, ephemeral
|
|
|
41
41
|
npx softschema@latest --help # Node implementation, ephemeral
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
Use `uvx softschema@0.2.
|
|
44
|
+
Use `uvx softschema@0.2.2` / `npx -y softschema@0.2.2` when a repeated ad-hoc run must
|
|
45
45
|
resolve the same version every time.
|
|
46
46
|
|
|
47
47
|
## Quick Start for Agents
|
|
@@ -335,6 +335,18 @@ A validator must reject:
|
|
|
335
335
|
- undeclared payload fields rejected by the `enforced` strictness rule (see Status
|
|
336
336
|
Values)
|
|
337
337
|
|
|
338
|
+
Validation output is deterministic across conforming implementations: structural error
|
|
339
|
+
records share an engine-neutral shape and message wording, and numbers — in an error
|
|
340
|
+
record’s `value`/`validator_value`, in a synthesized message, and in an echoed payload —
|
|
341
|
+
render in a canonical form, where a whole-valued number carries no trailing fraction
|
|
342
|
+
(`2`, not `2.0`).
|
|
343
|
+
Output is byte-identical for every number an implementation can represent exactly:
|
|
344
|
+
integers and whole-valued numbers within the IEEE-754 safe-integer range (`abs < 2^53`),
|
|
345
|
+
and ordinary floats.
|
|
346
|
+
A whole-valued magnitude at or beyond 2^53 is out of scope, because an arbitrary-precision
|
|
347
|
+
integer runtime and a double-only runtime cannot always render it identically; validated
|
|
348
|
+
payloads should avoid such literals.
|
|
349
|
+
|
|
338
350
|
## Generated Sections
|
|
339
351
|
|
|
340
352
|
A conforming implementation may regenerate Markdown sections from a compiled schema
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# softschema (Python)
|
|
2
|
+
|
|
3
|
+
Soft schemas: gradual, practical validation for Markdown/YAML artifacts that mix prose
|
|
4
|
+
and structured data—built for humans and coding agents.
|
|
5
|
+
|
|
6
|
+
This is the Python implementation of [softschema](https://github.com/jlevy/softschema),
|
|
7
|
+
published on [PyPI](https://pypi.org/project/softschema/). A fully synchronized
|
|
8
|
+
TypeScript implementation is also available on npm.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install softschema
|
|
14
|
+
# or:
|
|
15
|
+
uv add softschema
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from softschema import validate_artifact
|
|
24
|
+
|
|
25
|
+
# A self-describing artifact validates with no extra arguments; pass contract=,
|
|
26
|
+
# contract_id=, or registry= to bind a schema explicitly.
|
|
27
|
+
result = validate_artifact(Path("doc.md"))
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or from the command line:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
softschema validate doc.md
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Documentation
|
|
37
|
+
|
|
38
|
+
- [softschema Guide](https://github.com/jlevy/softschema/blob/main/docs/softschema-guide.md):
|
|
39
|
+
the full mental model and adoption playbooks
|
|
40
|
+
- [softschema Spec](https://github.com/jlevy/softschema/blob/main/docs/softschema-spec.md):
|
|
41
|
+
the exact artifact format and validation rules
|
|
42
|
+
- [Installation](https://github.com/jlevy/softschema/blob/main/docs/installation.md):
|
|
43
|
+
pinned vs zero-install, uv and Node setup
|
|
44
|
+
- [Repository](https://github.com/jlevy/softschema)
|
|
45
|
+
|
|
46
|
+
<!-- This document follows common-doc-guidelines.md.
|
|
47
|
+
See github.com/jlevy/practical-prose and review guidelines before editing.
|
|
48
|
+
-->
|
|
@@ -21,6 +21,7 @@ from ruamel.yaml import YAMLError
|
|
|
21
21
|
from strif import atomic_write_text
|
|
22
22
|
|
|
23
23
|
from softschema.compile import compile_model
|
|
24
|
+
from softschema.errors import canonical_number
|
|
24
25
|
from softschema.generate import regenerate
|
|
25
26
|
from softschema.models import Contract, SchemaStatus, parse_schema_metadata
|
|
26
27
|
from softschema.validate import EnvelopeAmbiguityError, infer_envelope_key, validate_artifact
|
|
@@ -42,19 +43,30 @@ AGENT_HELP_EPILOG = """IMPORTANT for agents:
|
|
|
42
43
|
Then read `softschema skill --brief` and `softschema docs --list` for operating rules
|
|
43
44
|
and bundled docs."""
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
|
|
47
|
+
class UsageError(ValueError):
|
|
48
|
+
"""A user/input mistake: bad flags, a bad model spec, or an unusable document.
|
|
49
|
+
|
|
50
|
+
Subclasses ``ValueError`` so it is reported through the CLI's user-error boundary
|
|
51
|
+
(clean one-line message, exit 2) and so library callers that already catch
|
|
52
|
+
``ValueError`` keep working.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Exit codes: 0 ok, 1 validation failure or drift (``--check``), 2 user/usage error.
|
|
57
|
+
# The families below are user mistakes (bad files, bad input, bad config), caught by the
|
|
58
|
+
# per-subcommand error boundary so an ordinary mistake never prints a traceback.
|
|
59
|
+
# ``TypeError`` and ``KeyError`` are deliberately excluded: nothing in the package raises
|
|
60
|
+
# them for user input, so they only ever signal an internal bug and must surface as a
|
|
61
|
+
# traceback rather than be masked as a clean exit 2.
|
|
48
62
|
_USER_ERRORS = (
|
|
49
63
|
OSError,
|
|
50
64
|
FmFormatError,
|
|
51
65
|
YAMLError,
|
|
52
66
|
ModuleNotFoundError,
|
|
53
67
|
ImportError,
|
|
54
|
-
TypeError,
|
|
55
|
-
ValueError,
|
|
56
68
|
ValidationError,
|
|
57
|
-
|
|
69
|
+
ValueError,
|
|
58
70
|
)
|
|
59
71
|
|
|
60
72
|
|
|
@@ -242,6 +254,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
242
254
|
)
|
|
243
255
|
generate_parser.set_defaults(func=_generate_cmd)
|
|
244
256
|
|
|
257
|
+
prime_parser = subparsers.add_parser(
|
|
258
|
+
"prime",
|
|
259
|
+
help="Print the full agent context: skill rules and the bundled docs index.",
|
|
260
|
+
)
|
|
261
|
+
prime_parser.set_defaults(func=_prime_cmd)
|
|
262
|
+
|
|
245
263
|
doctor_parser = subparsers.add_parser(
|
|
246
264
|
"doctor",
|
|
247
265
|
help="Report softschema version and runner availability.",
|
|
@@ -299,13 +317,13 @@ def _infer_validation_binding(
|
|
|
299
317
|
) -> tuple[str, SchemaStatus, str | None]:
|
|
300
318
|
if not isinstance(frontmatter, dict):
|
|
301
319
|
if args.contract is None:
|
|
302
|
-
raise
|
|
320
|
+
raise UsageError("missing --contract because the document has no YAML frontmatter")
|
|
303
321
|
return args.contract, _status_from_args(args, None), args.envelope
|
|
304
322
|
|
|
305
323
|
metadata = parse_schema_metadata(frontmatter.get("softschema"))
|
|
306
324
|
contract_id = args.contract or (metadata.contract_id if metadata is not None else None)
|
|
307
325
|
if contract_id is None:
|
|
308
|
-
raise
|
|
326
|
+
raise UsageError("missing --contract because the document has no softschema.contract")
|
|
309
327
|
|
|
310
328
|
return (
|
|
311
329
|
contract_id,
|
|
@@ -333,18 +351,16 @@ def _envelope_from_args(
|
|
|
333
351
|
try:
|
|
334
352
|
return infer_envelope_key(frontmatter)
|
|
335
353
|
except EnvelopeAmbiguityError as exc:
|
|
336
|
-
raise
|
|
354
|
+
raise UsageError(
|
|
337
355
|
"multiple top-level frontmatter keys; pass --envelope to designate the "
|
|
338
356
|
f"softschema payload (candidates: {', '.join(exc.candidates)})"
|
|
339
357
|
) from exc
|
|
340
358
|
|
|
341
359
|
|
|
342
360
|
def _compile_cmd(args: argparse.Namespace) -> int:
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
print(f"softschema compile: {exc}", file=sys.stderr)
|
|
347
|
-
return 2
|
|
361
|
+
# Model-load and compile errors (UsageError, OSError, ...) propagate to the shared
|
|
362
|
+
# `_run_cmd` boundary, which reports them as `softschema compile: ...` and exits 2.
|
|
363
|
+
model = _load_model(args.model)
|
|
348
364
|
result = compile_model(model, args.out, contract_id=args.contract, check_only=args.check)
|
|
349
365
|
print(_json(result))
|
|
350
366
|
return 1 if result.drift else 0
|
|
@@ -395,13 +411,27 @@ def _docs_cmd(args: argparse.Namespace) -> int:
|
|
|
395
411
|
return 0
|
|
396
412
|
|
|
397
413
|
|
|
414
|
+
def _prime_text() -> str:
|
|
415
|
+
"""Full agent context: the skill operating rules plus the bundled docs index.
|
|
416
|
+
|
|
417
|
+
Byte-identical to the TypeScript ``prime`` command (same SKILL.md, same listing).
|
|
418
|
+
"""
|
|
419
|
+
skill = _read_resource("skills/softschema/SKILL.md")
|
|
420
|
+
return f"{skill.rstrip()}\n\n{_docs_listing()}"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _prime_cmd(args: argparse.Namespace) -> int:
|
|
424
|
+
_write_text(_prime_text())
|
|
425
|
+
return 0
|
|
426
|
+
|
|
427
|
+
|
|
398
428
|
def _generate_cmd(args: argparse.Namespace) -> int:
|
|
399
429
|
any_drift = False
|
|
400
430
|
summary: list[dict[str, Any]] = []
|
|
401
431
|
for path in args.paths:
|
|
402
432
|
try:
|
|
403
433
|
result = regenerate(path, check=args.check)
|
|
404
|
-
except (OSError, ValueError
|
|
434
|
+
except (OSError, ValueError) as exc:
|
|
405
435
|
print(f"softschema generate: {path}: {exc}", file=sys.stderr)
|
|
406
436
|
return 2
|
|
407
437
|
any_drift = any_drift or result.drift
|
|
@@ -548,7 +578,7 @@ def _skill_cmd(args: argparse.Namespace) -> int:
|
|
|
548
578
|
def _load_model(spec: str) -> type[BaseModel]:
|
|
549
579
|
module_name, _, attr = spec.partition(":")
|
|
550
580
|
if not module_name or not attr:
|
|
551
|
-
raise
|
|
581
|
+
raise UsageError(f"model spec must be module:Class, got {spec!r}")
|
|
552
582
|
# Make the invoking directory importable so example modules outside the package
|
|
553
583
|
# (e.g. examples.movie_page.model) resolve when running the CLI from a checkout.
|
|
554
584
|
cwd = str(Path.cwd())
|
|
@@ -557,9 +587,9 @@ def _load_model(spec: str) -> type[BaseModel]:
|
|
|
557
587
|
module = importlib.import_module(module_name)
|
|
558
588
|
obj = getattr(module, attr, None)
|
|
559
589
|
if obj is None:
|
|
560
|
-
raise
|
|
590
|
+
raise UsageError(f"{spec!r} has no attribute {attr!r}")
|
|
561
591
|
if not isinstance(obj, type) or not issubclass(obj, BaseModel):
|
|
562
|
-
raise
|
|
592
|
+
raise UsageError(f"{spec!r} is not a Pydantic BaseModel class")
|
|
563
593
|
return obj
|
|
564
594
|
|
|
565
595
|
|
|
@@ -665,7 +695,9 @@ def _plain(value: Any) -> Any:
|
|
|
665
695
|
return [_plain(item) for item in value]
|
|
666
696
|
if isinstance(value, type):
|
|
667
697
|
return f"{value.__module__}:{value.__name__}"
|
|
668
|
-
|
|
698
|
+
# Canonical number form (whole-valued floats without a trailing `.0`) so the
|
|
699
|
+
# echoed `values` block matches the TypeScript CLI byte-for-byte; see errors.py.
|
|
700
|
+
return canonical_number(value)
|
|
669
701
|
|
|
670
702
|
|
|
671
703
|
if __name__ == "__main__":
|
|
@@ -18,6 +18,11 @@ Record shape (every structural validation error):
|
|
|
18
18
|
"message": "<synthesized, engine-neutral>",
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
Numeric values in a record (and in the rendered message) use a canonical form:
|
|
22
|
+
a whole-valued float renders without a trailing fraction (``2.0`` -> ``2``), the
|
|
23
|
+
JSON-natural form the TypeScript implementation emits natively. See
|
|
24
|
+
``canonical_number``.
|
|
25
|
+
|
|
21
26
|
softschema-level artifact errors (missing envelope, malformed metadata, ...)
|
|
22
27
|
use the separate ``{"kind": ..., "message": ...}`` shape produced by
|
|
23
28
|
``softschema.validate._error`` and are not routed through here.
|
|
@@ -30,6 +35,43 @@ from typing import Any
|
|
|
30
35
|
SCHEMA_VIOLATION_KIND = "schema_violation"
|
|
31
36
|
|
|
32
37
|
|
|
38
|
+
def canonical_number(value: Any) -> Any:
|
|
39
|
+
"""Render a whole-valued float in its canonical (integer) form.
|
|
40
|
+
|
|
41
|
+
JSON does not distinguish ``2`` from ``2.0``; the canonical softschema form
|
|
42
|
+
drops a redundant trailing fraction so numeric values render byte-identically
|
|
43
|
+
across the Python and TypeScript implementations (a YAML ``2.0`` token parses
|
|
44
|
+
as the integer ``2`` in JS, which has no int/float distinction to lose).
|
|
45
|
+
|
|
46
|
+
A whole-valued float below ``1e21`` is returned as ``int`` — that is exactly the
|
|
47
|
+
range where JavaScript serializes a whole-valued ``number`` in plain integer form
|
|
48
|
+
(``JSON.stringify``/``String``), so the Python ``int`` repr matches it. At or beyond
|
|
49
|
+
``1e21`` JavaScript switches to exponential notation, which Python's float repr also
|
|
50
|
+
uses, so those floats are left unchanged. Ints, bools, and non-whole floats are
|
|
51
|
+
returned unchanged.
|
|
52
|
+
|
|
53
|
+
Byte-parity is guaranteed within the IEEE-754 safe-integer range (``abs < 2**53``).
|
|
54
|
+
A larger *non-round* integer-valued magnitude cannot render identically on a
|
|
55
|
+
double-only runtime and an arbitrary-precision one (Python's exact ``int`` vs JS's
|
|
56
|
+
shortest round-trip), and is out of scope — see the golden corpus README, "Number
|
|
57
|
+
formatting", edge (b).
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(value, float) and value.is_integer() and abs(value) < 1e21:
|
|
60
|
+
return int(value)
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _canonical(value: Any) -> Any:
|
|
65
|
+
"""Apply :func:`canonical_number` recursively through lists and dicts."""
|
|
66
|
+
if isinstance(value, list):
|
|
67
|
+
return [_canonical(v) for v in value]
|
|
68
|
+
if isinstance(value, tuple):
|
|
69
|
+
return tuple(_canonical(v) for v in value)
|
|
70
|
+
if isinstance(value, dict):
|
|
71
|
+
return {k: _canonical(v) for k, v in value.items()}
|
|
72
|
+
return canonical_number(value)
|
|
73
|
+
|
|
74
|
+
|
|
33
75
|
def _fmt(value: Any) -> str:
|
|
34
76
|
"""Render a value compactly and deterministically for messages."""
|
|
35
77
|
return repr(value)
|
|
@@ -50,8 +92,12 @@ def render_structural_message(
|
|
|
50
92
|
|
|
51
93
|
The wording here is the cross-language contract: both the Python and the
|
|
52
94
|
TypeScript implementations must produce byte-identical strings, so this
|
|
53
|
-
template table is the single source of truth.
|
|
95
|
+
template table is the single source of truth. Numeric values are rendered in
|
|
96
|
+
their canonical form (whole-valued floats without a trailing fraction) so the
|
|
97
|
+
string matches the TS renderer, which has no float/int distinction.
|
|
54
98
|
"""
|
|
99
|
+
value = _canonical(value)
|
|
100
|
+
validator_value = _canonical(validator_value)
|
|
55
101
|
if validator == "enum":
|
|
56
102
|
return f"value {_fmt(value)} is not one of [{_fmt_list(validator_value)}]"
|
|
57
103
|
if validator == "type":
|
|
@@ -92,6 +138,10 @@ def structural_error_record(
|
|
|
92
138
|
value: Any,
|
|
93
139
|
) -> dict[str, Any]:
|
|
94
140
|
"""Build one engine-neutral structural error record."""
|
|
141
|
+
# Store numbers in canonical form so the echoed `value`/`validator_value`
|
|
142
|
+
# fields match the rendered message and the TS records byte-for-byte.
|
|
143
|
+
validator_value = _canonical(validator_value)
|
|
144
|
+
value = _canonical(value)
|
|
95
145
|
return {
|
|
96
146
|
"kind": SCHEMA_VIOLATION_KIND,
|
|
97
147
|
"path": path,
|
|
@@ -117,4 +117,4 @@ def parse_schema_metadata(raw: Any) -> SchemaMetadata | None:
|
|
|
117
117
|
if isinstance(raw, dict):
|
|
118
118
|
return SchemaMetadata.model_validate(raw)
|
|
119
119
|
msg = f"softschema metadata must be a string or mapping, got {type(raw).__name__}"
|
|
120
|
-
raise
|
|
120
|
+
raise ValueError(msg)
|
|
@@ -407,7 +407,7 @@ def _metadata_from_frontmatter(
|
|
|
407
407
|
) -> SchemaMetadata | ArtifactValidationResult | None:
|
|
408
408
|
try:
|
|
409
409
|
metadata = parse_schema_metadata(frontmatter.get("softschema"))
|
|
410
|
-
except (
|
|
410
|
+
except (ValueError, ValidationError) as exc:
|
|
411
411
|
return _artifact_failure(
|
|
412
412
|
doc_path,
|
|
413
413
|
contract,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import argparse
|
|
3
4
|
import json
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from textwrap import dedent
|
|
@@ -510,3 +511,41 @@ def test_inspect_malformed_softschema_block_exits_two(
|
|
|
510
511
|
err = capsys.readouterr().err
|
|
511
512
|
assert "softschema inspect:" in err
|
|
512
513
|
assert "Traceback" not in err
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def test_run_cmd_surfaces_internal_bugs() -> None:
|
|
517
|
+
"""TypeError/KeyError signal internal bugs and must surface, not be masked as exit 2."""
|
|
518
|
+
|
|
519
|
+
def raise_type(_args: object) -> int:
|
|
520
|
+
raise TypeError("internal bug")
|
|
521
|
+
|
|
522
|
+
def raise_key(_args: object) -> int:
|
|
523
|
+
raise KeyError("missing")
|
|
524
|
+
|
|
525
|
+
with pytest.raises(TypeError):
|
|
526
|
+
cli._run_cmd("validate", raise_type, argparse.Namespace())
|
|
527
|
+
with pytest.raises(KeyError):
|
|
528
|
+
cli._run_cmd("validate", raise_key, argparse.Namespace())
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def test_run_cmd_reports_usage_error_as_exit_2(capsys: pytest.CaptureFixture[str]) -> None:
|
|
532
|
+
"""A UsageError (and any ValueError) is a user mistake: clean one-liner, exit 2."""
|
|
533
|
+
|
|
534
|
+
def raise_usage(_args: object) -> int:
|
|
535
|
+
raise cli.UsageError("bad flag")
|
|
536
|
+
|
|
537
|
+
exit_code = cli._run_cmd("validate", raise_usage, argparse.Namespace())
|
|
538
|
+
|
|
539
|
+
assert exit_code == 2
|
|
540
|
+
assert "softschema validate: bad flag" in capsys.readouterr().err
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def test_prime_prints_skill_and_docs_index(capsys: pytest.CaptureFixture[str]) -> None:
|
|
544
|
+
"""`prime` restores full agent context: skill operating rules + the bundled docs index."""
|
|
545
|
+
exit_code = softschema_main(["prime"])
|
|
546
|
+
|
|
547
|
+
assert exit_code == 0
|
|
548
|
+
out = capsys.readouterr().out
|
|
549
|
+
assert "softschema" in out # skill content
|
|
550
|
+
assert "Available softschema docs:" in out # docs index
|
|
551
|
+
assert "Run `softschema docs <topic>`" in out
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from softschema import compile_model, validate_structural
|
|
8
|
+
from softschema.errors import canonical_number, render_structural_message, structural_error_record
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Sample(BaseModel):
|
|
12
|
+
model_config = ConfigDict(extra="forbid")
|
|
13
|
+
|
|
14
|
+
count: int = Field(ge=0, le=10)
|
|
15
|
+
label: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_render_structural_message_is_engine_neutral() -> None:
|
|
19
|
+
# The wording is the cross-language contract; pin a few keywords exactly.
|
|
20
|
+
assert render_structural_message("minimum", 0, -1) == "value -1 is less than the minimum of 0"
|
|
21
|
+
assert (
|
|
22
|
+
render_structural_message("enum", ["G", "PG"], "X") == "value 'X' is not one of ['G', 'PG']"
|
|
23
|
+
)
|
|
24
|
+
assert render_structural_message("type", "integer", "x") == "value 'x' is not of type 'integer'"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_canonical_number_drops_trailing_fraction() -> None:
|
|
28
|
+
# ss-wbnm: a whole-valued float renders in canonical (int) form so it is
|
|
29
|
+
# byte-identical to the TypeScript impl, which has no float/int distinction.
|
|
30
|
+
assert canonical_number(2.0) == 2
|
|
31
|
+
assert isinstance(canonical_number(2.0), int)
|
|
32
|
+
assert canonical_number(-2.0) == -2
|
|
33
|
+
assert canonical_number(0.0) == 0
|
|
34
|
+
# Whole floats below 1e21 become canonical ints (the range where JS renders a
|
|
35
|
+
# whole-valued number as a plain integer via String()/JSON.stringify()).
|
|
36
|
+
assert canonical_number(1e15) == 1000000000000000
|
|
37
|
+
assert isinstance(canonical_number(1e15), int)
|
|
38
|
+
assert canonical_number(1e16) == 10000000000000000 # the reviewer's divergence case
|
|
39
|
+
assert isinstance(canonical_number(1e16), int)
|
|
40
|
+
assert canonical_number(1e20) == 100000000000000000000
|
|
41
|
+
# Non-whole floats keep their fraction; ints and bools are untouched.
|
|
42
|
+
assert canonical_number(0.3) == 0.3
|
|
43
|
+
assert canonical_number(7) == 7
|
|
44
|
+
assert canonical_number(True) is True
|
|
45
|
+
# Floats at/beyond 1e21 keep exponential repr (matches the TS formatter and JS String).
|
|
46
|
+
assert canonical_number(1e21) == 1e21
|
|
47
|
+
assert isinstance(canonical_number(1e21), float)
|
|
48
|
+
assert repr(canonical_number(1e21)) == "1e+21"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_whole_float_renders_canonically_in_messages_and_records() -> None:
|
|
52
|
+
# The bound 2.0 and the offending 1.0 both render without a trailing `.0`.
|
|
53
|
+
assert render_structural_message("minimum", 2.0, 1.0) == "value 1 is less than the minimum of 2"
|
|
54
|
+
assert render_structural_message("enum", [1.0, 2.0], 3.0) == "value 3 is not one of [1, 2]"
|
|
55
|
+
record = structural_error_record(
|
|
56
|
+
path=["ratio"], validator="minimum", validator_value=2.0, value=1.0
|
|
57
|
+
)
|
|
58
|
+
# Stored fields are canonicalized too, so they match the message and the TS record.
|
|
59
|
+
assert record["value"] == 1
|
|
60
|
+
assert record["validator_value"] == 2
|
|
61
|
+
assert record["message"] == "value 1 is less than the minimum of 2"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_structural_error_record_shape() -> None:
|
|
65
|
+
record = structural_error_record(
|
|
66
|
+
path=["count"], validator="maximum", validator_value=10, value=11
|
|
67
|
+
)
|
|
68
|
+
assert record == {
|
|
69
|
+
"kind": "schema_violation",
|
|
70
|
+
"path": ["count"],
|
|
71
|
+
"validator": "maximum",
|
|
72
|
+
"validator_value": 10,
|
|
73
|
+
"value": 11,
|
|
74
|
+
"message": "value 11 is greater than the maximum of 10",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_validate_structural_emits_neutral_records(tmp_path: Path) -> None:
|
|
79
|
+
schema_path = tmp_path / "sample.schema.yaml"
|
|
80
|
+
compile_model(Sample, schema_path, contract_id="example:Sample/v1")
|
|
81
|
+
|
|
82
|
+
result = validate_structural({"count": 99, "label": "ok"}, schema_path)
|
|
83
|
+
|
|
84
|
+
assert not result.ok
|
|
85
|
+
error = result.errors[0]
|
|
86
|
+
assert error["kind"] == "schema_violation"
|
|
87
|
+
assert error["validator"] == "maximum"
|
|
88
|
+
assert "greater than the maximum" in error["message"]
|
|
@@ -46,12 +46,15 @@ dev = [
|
|
|
46
46
|
]
|
|
47
47
|
|
|
48
48
|
[tool.uv]
|
|
49
|
-
#
|
|
50
|
-
# zero-dependency, first-party package,
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
49
|
+
# Local resolution honors the supply-chain cool-off: cap dependencies at a reviewed
|
|
50
|
+
# cutoff (bump deliberately). strif is a reviewed, zero-dependency, first-party package,
|
|
51
|
+
# so it gets a per-package exception to that cutoff. Both values need a full RFC3339
|
|
52
|
+
# timestamp -- a date-only string fails to parse and uv silently drops the setting. CI
|
|
53
|
+
# sets a global UV_EXCLUDE_NEWER (14 days ago) that overrides `exclude-newer` here, and
|
|
54
|
+
# because that env override also replaces the per-package map, ci.yml re-passes
|
|
55
|
+
# `--exclude-newer-package strif=...` on the CLI. Bump the dates together.
|
|
56
|
+
exclude-newer = "2026-06-02T00:00:00Z"
|
|
57
|
+
exclude-newer-package = { strif = "2026-06-03T00:00:00Z" }
|
|
55
58
|
|
|
56
59
|
[build-system]
|
|
57
60
|
requires = ["hatchling", "uv-dynamic-versioning"]
|
|
@@ -56,21 +56,27 @@ Otherwise no patterns are needed. Note in particular:
|
|
|
56
56
|
streams with `2>&1` and asserts the stable `softschema <cmd>:` prefix plus exit code,
|
|
57
57
|
eliding the engine-specific tail with `[..]`/`...`. Where the wording is identical
|
|
58
58
|
(ambiguous envelope, missing implementation) it is asserted in full on stderr (`!`).
|
|
59
|
-
- **Number formatting
|
|
60
|
-
|
|
61
|
-
- **(a) Whole-
|
|
62
|
-
|
|
63
|
-
(`
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
`
|
|
72
|
-
|
|
73
|
-
|
|
59
|
+
- **Number formatting.** Both implementations render numbers to match Python's `repr()`.
|
|
60
|
+
Numbers follow one canonical rule; one genuine edge case remains:
|
|
61
|
+
- **(a) Whole-valued numbers render in canonical form** (`ss-wbnm`, resolved). A
|
|
62
|
+
whole-valued number below 1e21 renders without a trailing fraction and without an
|
|
63
|
+
exponent (`2.0` -> `2`, `1.0e16` -> `10000000000000000`) — the form JS emits natively
|
|
64
|
+
(it has no int/float distinction, and `String()`/`JSON.stringify()` print a
|
|
65
|
+
whole-valued number in plain integer notation below 1e21). The Python side converts
|
|
66
|
+
its whole-valued floats below 1e21 to int to match (`canonical_number` in `errors.py`,
|
|
67
|
+
applied to error records and the `values` echo), so such a number is byte-identical in
|
|
68
|
+
a `value`, `validator_value`, or message on both engines. Non-whole floats (`0.3`,
|
|
69
|
+
`8.6`) keep their fraction. The `error-normalization` scenario exercises the small case
|
|
70
|
+
(`ratio: 1.0` vs `minimum: 2.0`), and `number-large-whole` the large case (`9.0e15` vs
|
|
71
|
+
`minimum: 1.0e16`, both rendering full integers).
|
|
72
|
+
- **(b) Non-round integer-valued magnitudes >= 2^53 diverge.** Byte-parity is guaranteed
|
|
73
|
+
only within the IEEE-754 safe-integer range. Above 2^53 not every integer is
|
|
74
|
+
representable, so a *non-round* value differs between Python's exact `int`
|
|
75
|
+
(`9.999999999999998e16` -> `99999999999999984`) and JS's shortest round-trip
|
|
76
|
+
(`99999999999999980`); likewise a YAML integer literal like `9007199254740993` keeps
|
|
77
|
+
full precision in Python but loses it as a JS double. Round magnitudes (powers/simple
|
|
78
|
+
multiples of ten) below 1e21 still match — see (a) — but avoid non-round integer-valued
|
|
79
|
+
literals >= 2^53 in error-exercising fixtures.
|
|
74
80
|
|
|
75
81
|
## Updating
|
|
76
82
|
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# softschema Python Package
|
|
2
|
-
|
|
3
|
-
The Python package provides:
|
|
4
|
-
|
|
5
|
-
- `Contract` and `Contracts`
|
|
6
|
-
- `validate_artifact` for Markdown/YAML artifact validation
|
|
7
|
-
- `validate_structural` for JSON Schema validation
|
|
8
|
-
- `validate_semantic` for Pydantic validation
|
|
9
|
-
- `compile_model` for Pydantic-to-JSON-Schema compilation
|
|
10
|
-
- the `softschema` CLI, including bundled docs through `softschema docs` and
|
|
11
|
-
`softschema skill`
|
|
12
|
-
|
|
13
|
-
The package source lives under `packages/python/src/softschema`, but the root
|
|
14
|
-
`pyproject.toml` owns builds and dependency management.
|
|
15
|
-
|
|
16
|
-
Use the root [softschema Guide](../../docs/softschema-guide.md) for the concept and
|
|
17
|
-
[softschema Spec](../../docs/softschema-spec.md) for the artifact format.
|
|
18
|
-
|
|
19
|
-
Installed environments can print the same reference material:
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
softschema docs --list
|
|
23
|
-
softschema docs --list --json
|
|
24
|
-
softschema docs guide
|
|
25
|
-
softschema docs example-artifact
|
|
26
|
-
softschema skill --brief
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
<!-- This document follows common-doc-guidelines.md.
|
|
30
|
-
See github.com/jlevy/practical-prose and review guidelines before editing.
|
|
31
|
-
-->
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from pathlib import Path
|
|
4
|
-
|
|
5
|
-
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
-
|
|
7
|
-
from softschema import compile_model, validate_structural
|
|
8
|
-
from softschema.errors import render_structural_message, structural_error_record
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class Sample(BaseModel):
|
|
12
|
-
model_config = ConfigDict(extra="forbid")
|
|
13
|
-
|
|
14
|
-
count: int = Field(ge=0, le=10)
|
|
15
|
-
label: str
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_render_structural_message_is_engine_neutral() -> None:
|
|
19
|
-
# The wording is the cross-language contract; pin a few keywords exactly.
|
|
20
|
-
assert render_structural_message("minimum", 0, -1) == "value -1 is less than the minimum of 0"
|
|
21
|
-
assert (
|
|
22
|
-
render_structural_message("enum", ["G", "PG"], "X") == "value 'X' is not one of ['G', 'PG']"
|
|
23
|
-
)
|
|
24
|
-
assert render_structural_message("type", "integer", "x") == "value 'x' is not of type 'integer'"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def test_structural_error_record_shape() -> None:
|
|
28
|
-
record = structural_error_record(
|
|
29
|
-
path=["count"], validator="maximum", validator_value=10, value=11
|
|
30
|
-
)
|
|
31
|
-
assert record == {
|
|
32
|
-
"kind": "schema_violation",
|
|
33
|
-
"path": ["count"],
|
|
34
|
-
"validator": "maximum",
|
|
35
|
-
"validator_value": 10,
|
|
36
|
-
"value": 11,
|
|
37
|
-
"message": "value 11 is greater than the maximum of 10",
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_validate_structural_emits_neutral_records(tmp_path: Path) -> None:
|
|
42
|
-
schema_path = tmp_path / "sample.schema.yaml"
|
|
43
|
-
compile_model(Sample, schema_path, contract_id="example:Sample/v1")
|
|
44
|
-
|
|
45
|
-
result = validate_structural({"count": 99, "label": "ok"}, schema_path)
|
|
46
|
-
|
|
47
|
-
assert not result.ok
|
|
48
|
-
error = result.errors[0]
|
|
49
|
-
assert error["kind"] == "schema_violation"
|
|
50
|
-
assert error["validator"] == "maximum"
|
|
51
|
-
assert "greater than the maximum" in error["message"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|