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.
Files changed (58) hide show
  1. {softschema-0.2.1 → softschema-0.2.2}/PKG-INFO +3 -3
  2. {softschema-0.2.1 → softschema-0.2.2}/README.md +2 -2
  3. {softschema-0.2.1 → softschema-0.2.2}/docs/installation.md +4 -4
  4. {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-spec.md +12 -0
  5. softschema-0.2.2/packages/python/README.md +48 -0
  6. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/cli.py +51 -19
  7. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/errors.py +51 -1
  8. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/models.py +1 -1
  9. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/validate.py +1 -1
  10. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_cli.py +39 -0
  11. softschema-0.2.2/packages/python/tests/test_errors.py +88 -0
  12. {softschema-0.2.1 → softschema-0.2.2}/pyproject.toml +9 -6
  13. {softschema-0.2.1 → softschema-0.2.2}/tests/golden/README.md +21 -15
  14. softschema-0.2.1/packages/python/README.md +0 -31
  15. softschema-0.2.1/packages/python/tests/test_errors.py +0 -51
  16. {softschema-0.2.1 → softschema-0.2.2}/.gitignore +0 -0
  17. {softschema-0.2.1 → softschema-0.2.2}/AGENTS.md +0 -0
  18. {softschema-0.2.1 → softschema-0.2.2}/LICENSE +0 -0
  19. {softschema-0.2.1 → softschema-0.2.2}/docs/development.md +0 -0
  20. {softschema-0.2.1 → softschema-0.2.2}/docs/e2e-testing.runbook.md +0 -0
  21. {softschema-0.2.1 → softschema-0.2.2}/docs/publishing-npm.md +0 -0
  22. {softschema-0.2.1 → softschema-0.2.2}/docs/publishing.md +0 -0
  23. {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-guide.md +0 -0
  24. {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-python-design.md +0 -0
  25. {softschema-0.2.1 → softschema-0.2.2}/docs/softschema-typescript-design.md +0 -0
  26. {softschema-0.2.1 → softschema-0.2.2}/examples/__init__.py +0 -0
  27. {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/README.md +0 -0
  28. {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/__init__.py +0 -0
  29. {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/host_integration.py +0 -0
  30. {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/model.py +0 -0
  31. {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/movie-page.schema.yaml +0 -0
  32. {softschema-0.2.1 → softschema-0.2.2}/examples/movie_page/spirited-away.md +0 -0
  33. {softschema-0.2.1 → softschema-0.2.2}/examples/parity/__init__.py +0 -0
  34. {softschema-0.2.1 → softschema-0.2.2}/examples/parity/model.py +0 -0
  35. {softschema-0.2.1 → softschema-0.2.2}/examples/parity/parity.schema.yaml +0 -0
  36. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/__init__.py +0 -0
  37. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/canonicalize.py +0 -0
  38. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/compile.py +0 -0
  39. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/generate.py +0 -0
  40. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/py.typed +0 -0
  41. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/registry.py +0 -0
  42. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/schema_view.py +0 -0
  43. {softschema-0.2.1 → softschema-0.2.2}/packages/python/src/softschema/soft_field.py +0 -0
  44. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_canonicalize.py +0 -0
  45. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_core.py +0 -0
  46. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_doc_topics_resolve.py +0 -0
  47. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_enforced_extras.py +0 -0
  48. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_generate.py +0 -0
  49. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_metadata_rules.py +0 -0
  50. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_movie_page_example.py +0 -0
  51. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_parity.py +0 -0
  52. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_schema_view.py +0 -0
  53. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_skill_mirror_drift.py +0 -0
  54. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_soft_field.py +0 -0
  55. {softschema-0.2.1 → softschema-0.2.2}/packages/python/tests/test_warning_codes.py +0 -0
  56. {softschema-0.2.1 → softschema-0.2.2}/packages/typescript/LICENSE +0 -0
  57. {softschema-0.2.1 → softschema-0.2.2}/packages/typescript/README.md +0 -0
  58. {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.1
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.1 # Python
236
- npm install -D softschema@0.2.1 # Node (or: bun add -d)
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.1 # Python
208
- npm install -D softschema@0.2.1 # Node (or: bun add -d)
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.1`) |
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.1 # project dev dependency; run via `uv run softschema`
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.1 # or: pnpm add -D / bun add -d
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.1` / `npx -y softschema@0.2.1` when a repeated ad-hoc run must
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
- # Exception families that represent user mistakes (bad files, bad input, bad
46
- # config) rather than internal bugs. Caught by the per-subcommand error
47
- # boundary so that ordinary mistakes never produce a traceback.
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
- KeyError,
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 ValueError("missing --contract because the document has no YAML frontmatter")
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 ValueError("missing --contract because the document has no softschema.contract")
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 ValueError(
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
- try:
344
- model = _load_model(args.model)
345
- except (TypeError, ValueError) as exc:
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, KeyError) as exc:
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 ValueError(f"model spec must be module:Class, got {spec!r}")
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 ValueError(f"{spec!r} has no attribute {attr!r}")
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 TypeError(f"{spec!r} is not a Pydantic BaseModel class")
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
- return value
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 TypeError(msg)
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 (TypeError, ValidationError) as exc:
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
- # Trusted-package exception to the 14-day supply-chain cool-off. strif is a reviewed,
50
- # zero-dependency, first-party package, so the latest release is allowed. This records
51
- # the decision and applies for local resolution; CI sets a global UV_EXCLUDE_NEWER that
52
- # overrides config, so ci.yml ALSO passes `--exclude-newer-package strif=...` with the
53
- # same date. Bump the date in both places to adopt a newer strif.
54
- exclude-newer-package = { strif = "2026-06-03" }
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 limitations.** The `pyRepr` formatter in both implementations
60
- renders numbers to match Python's `repr()`, but two edge cases cannot be aligned:
61
- - **(a) Whole-number floats below 1e16 render without `.0` on TS** (`ss-wbnm`). Python
62
- preserves the int/float distinction from the YAML source token
63
- (`repr(2.0) == "2.0"`); JS collapses `2.0` to `2` at parse, so a whole-number float
64
- renders as `2` on the TypeScript side. Keep golden values that appear in a `value`,
65
- `validator_value`, or message as integers or **non-whole** floats (`0.3`, `8.6`) so
66
- the corpus stays byte-identical on both engines. Non-error values elsewhere are
67
- unaffected.
68
- - **(b) Integer literals >= 2^53 diverge.** Python has arbitrary-precision integers
69
- (`repr(10000000000000000)` is `10000000000000000`), while JS collapses large integer
70
- literals into IEEE 754 doubles, losing precision. A YAML integer literal like
71
- `10000000000000000` stays `int` in Python but becomes a float in JS (same numeric
72
- value, different repr). Avoid integer-valued literals >= 2^53 in error-exercising
73
- fixtures.
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