pytypehint 1.2.2__tar.gz → 1.3.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.
Files changed (117) hide show
  1. {pytypehint-1.2.2 → pytypehint-1.3.0}/CHANGELOG.md +22 -0
  2. {pytypehint-1.2.2 → pytypehint-1.3.0}/PKG-INFO +14 -13
  3. pytypehint-1.3.0/README.md +43 -0
  4. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/contract.md +2 -1
  5. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/decode.md +1 -1
  6. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/design.md +1 -1
  7. pytypehint-1.3.0/docs/encode.md +74 -0
  8. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/immutable.md +1 -1
  9. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/overview.md +5 -3
  10. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/vocabulary.md +2 -2
  11. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/__init__.py +1 -1
  12. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/contract.py +2 -41
  13. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/signature.py +4 -1
  14. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/structure.py +69 -0
  15. pytypehint-1.3.0/tests/test_encode.py +447 -0
  16. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_hardening_a.py +1 -1
  17. pytypehint-1.2.2/README.md +0 -42
  18. {pytypehint-1.2.2 → pytypehint-1.3.0}/.github/dependabot.yml +0 -0
  19. {pytypehint-1.2.2 → pytypehint-1.3.0}/.github/workflows/ci.yml +0 -0
  20. {pytypehint-1.2.2 → pytypehint-1.3.0}/.github/workflows/publish.yml +0 -0
  21. {pytypehint-1.2.2 → pytypehint-1.3.0}/.gitignore +0 -0
  22. {pytypehint-1.2.2 → pytypehint-1.3.0}/LICENSE +0 -0
  23. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/atoms.md +0 -0
  24. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/build.md +0 -0
  25. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/defaults.md +0 -0
  26. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/limits.md +0 -0
  27. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/resolve.md +0 -0
  28. {pytypehint-1.2.2 → pytypehint-1.3.0}/docs/tuples.md +0 -0
  29. {pytypehint-1.2.2 → pytypehint-1.3.0}/pyproject.toml +0 -0
  30. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/atoms.py +0 -0
  31. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/bridge.py +0 -0
  32. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/errors.py +0 -0
  33. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/immutable.py +0 -0
  34. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/py.typed +0 -0
  35. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/shapes.py +0 -0
  36. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/utils.py +0 -0
  37. {pytypehint-1.2.2 → pytypehint-1.3.0}/src/pytypehint/validation.py +0 -0
  38. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/conftest.py +0 -0
  39. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_annotated.py +0 -0
  40. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_annotated_hoisting_properties.py +0 -0
  41. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_atom_argument_validation.py +0 -0
  42. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_atom_table_reflection.py +0 -0
  43. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_atoms.py +0 -0
  44. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_bool.py +0 -0
  45. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_bridge.py +0 -0
  46. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_build.py +0 -0
  47. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_certification.py +0 -0
  48. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_collection_and_recursion_matrix.py +0 -0
  49. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_composition.py +0 -0
  50. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_contract.py +0 -0
  51. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_contract_graph.py +0 -0
  52. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_crossround_x.py +0 -0
  53. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_crossround_y.py +0 -0
  54. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_datetime.py +0 -0
  55. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_decode.py +0 -0
  56. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_decode_boundary.py +0 -0
  57. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_decode_routing.py +0 -0
  58. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_default_container_isolation.py +0 -0
  59. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_default_types.py +0 -0
  60. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_discriminated_wrapper.py +0 -0
  61. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_discriminator.py +0 -0
  62. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_documentation_contract.py +0 -0
  63. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_enum.py +0 -0
  64. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_equality.py +0 -0
  65. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_errors_pickle.py +0 -0
  66. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_extra.py +0 -0
  67. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_field_dataclass_surface.py +0 -0
  68. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_file_hint.py +0 -0
  69. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_final_restrictions.py +0 -0
  70. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_float.py +0 -0
  71. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_hardening_b.py +0 -0
  72. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_hardening_c.py +0 -0
  73. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_hardening_d.py +0 -0
  74. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_hardening_e.py +0 -0
  75. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_hardening_f.py +0 -0
  76. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_hardening_g.py +0 -0
  77. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_immutable.py +0 -0
  78. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_initvar_rejection.py +0 -0
  79. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_instance_validation_and_homonym_enums.py +0 -0
  80. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_int.py +0 -0
  81. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_list.py +0 -0
  82. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_literal.py +0 -0
  83. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_matches_no_option_notes.py +0 -0
  84. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_missing.py +0 -0
  85. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_none.py +0 -0
  86. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_none_transparency.py +0 -0
  87. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_numeric_constraint_validation.py +0 -0
  88. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_optional_toggle.py +0 -0
  89. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_portable_properties.py +0 -0
  90. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_presentation_atoms_surface.py +0 -0
  91. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_recursive.py +0 -0
  92. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_remat_runtime_failure.py +0 -0
  93. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_rematerialization.py +0 -0
  94. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_resolve.py +0 -0
  95. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_scalar_constraint_validation.py +0 -0
  96. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_scalar_option_identity.py +0 -0
  97. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_scalar_type_strictness.py +0 -0
  98. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_schema_model_invariants.py +0 -0
  99. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_shape_constraint_certification.py +0 -0
  100. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_shape_invariants.py +0 -0
  101. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_signature.py +0 -0
  102. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_signature_build.py +0 -0
  103. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_signature_field_rejection.py +0 -0
  104. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_single_pass_validation.py +0 -0
  105. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_str.py +0 -0
  106. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_string_and_literal_integration.py +0 -0
  107. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_struct.py +0 -0
  108. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_structured_errors.py +0 -0
  109. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_temporal_constraint_validation.py +0 -0
  110. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_time_second_precision.py +0 -0
  111. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_tuple.py +0 -0
  112. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_union_field_atoms.py +0 -0
  113. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_union_option_collisions.py +0 -0
  114. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_union_routing_matrix.py +0 -0
  115. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_unions.py +0 -0
  116. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_validate.py +0 -0
  117. {pytypehint-1.2.2 → pytypehint-1.3.0}/tests/test_wrapper_layer_composition.py +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-11-13
4
+
5
+ - Add `Struct.encode(instance)` and `Signature.encode(kwargs)`: write a valid
6
+ value as the portable tree `decode` reads. For a valid value,
7
+ `schema.build(schema.decode(schema.encode(value)))` returns it with its types,
8
+ also after a JSON transport.
9
+ - Name union options exactly as `to_dict()` already does for defaults; both now
10
+ share one writer. The portable format remains `v: 1` and exported documents
11
+ are unchanged.
12
+ - Encode does not validate. It rejects only what it cannot write: a wrong root
13
+ type, a value belonging to no option of its slot, a fixed tuple of the wrong
14
+ length and unknown signature arguments, each with its `path`.
15
+ - The public surface grows by no new names: both are methods on the compiled
16
+ schemas. Existing operations retain their behavior.
17
+
18
+ ## 1.2.3 - 2026-09-29
19
+
20
+ ### Changed
21
+
22
+ - Documentation only: the README's Documentation list links each page on the
23
+ documentation site, so readers on GitHub and PyPI land there. The code is the same as 1.2.2.
24
+
3
25
  ## 1.2.2 - 2026-09-29
4
26
 
5
27
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytypehint
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: Compiles Python type hints into strict, inspectable, portable contracts
5
5
  Project-URL: Documentation, https://offerrall.github.io/pytypehint/
6
6
  Project-URL: Repository, https://github.com/offerrall/pytypehint
@@ -52,15 +52,16 @@ The full documentation is at https://offerrall.github.io/pytypehint/.
52
52
 
53
53
  ## Documentation
54
54
 
55
- - [Overview](docs/overview.md): immutable models, portable data, field metadata and the four operations.
56
- - [Immutable models](docs/immutable.md): the `@immutable` contract, allowed fields, reuse and Python behavior.
57
- - [Types and API](docs/vocabulary.md): which hints compile to which shapes, and the public API.
58
- - [Atoms](docs/atoms.md): constraints and notation per shape, file names, layering and extras.
59
- - [Build](docs/build.md): validating and constructing instances or keyword arguments, union selection and errors.
60
- - [Resolve](docs/resolve.md): validated dictionaries with defaults filled at the current level.
61
- - [Decode](docs/decode.md): restoring portable values such as dates, enums and tuples before validation.
62
- - [Tuples](docs/tuples.md): fixed, variadic and empty tuples and the `Tuple` shape.
63
- - [Defaults](docs/defaults.md): certification and rematerialization of default values.
64
- - [Portable contract](docs/contract.md): the JSON-compatible schema document returned by `to_dict()`.
65
- - [Limits](docs/limits.md): rejected definitions, option identity and runtime limits.
66
- - [Design](docs/design.md): what the core owns, what consumers own, and why.
55
+ - [Overview](https://offerrall.github.io/pytypehint/): immutable models, portable data, field metadata and the five operations.
56
+ - [Immutable models](https://offerrall.github.io/pytypehint/immutable/): the `@immutable` contract, allowed fields, reuse and Python behavior.
57
+ - [Types and API](https://offerrall.github.io/pytypehint/vocabulary/): which hints compile to which shapes, and the public API.
58
+ - [Atoms](https://offerrall.github.io/pytypehint/atoms/): constraints and notation per shape, file names, layering and extras.
59
+ - [Build](https://offerrall.github.io/pytypehint/build/): validating and constructing instances or keyword arguments, union selection and errors.
60
+ - [Resolve](https://offerrall.github.io/pytypehint/resolve/): validated dictionaries with defaults filled at the current level.
61
+ - [Decode](https://offerrall.github.io/pytypehint/decode/): restoring portable values such as dates, enums and tuples before validation.
62
+ - [Encode](https://offerrall.github.io/pytypehint/encode/): writing valid values as portable data, the inverse of decode.
63
+ - [Tuples](https://offerrall.github.io/pytypehint/tuples/): fixed, variadic and empty tuples and the `Tuple` shape.
64
+ - [Defaults](https://offerrall.github.io/pytypehint/defaults/): certification and rematerialization of default values.
65
+ - [Portable contract](https://offerrall.github.io/pytypehint/contract/): the JSON-compatible schema document returned by `to_dict()`.
66
+ - [Limits](https://offerrall.github.io/pytypehint/limits/): rejected definitions, option identity and runtime limits.
67
+ - [Design](https://offerrall.github.io/pytypehint/design/): what the core owns, what consumers own, and why.
@@ -0,0 +1,43 @@
1
+ # pytypehint
2
+
3
+ Define data once with ordinary Python type hints and dataclasses. pytypehint
4
+ compiles them into strict schemas that validate input with exact types and no
5
+ implicit coercion, build nested dataclasses, and export a portable description
6
+ another process can read.
7
+
8
+ Use `@immutable` for deeply immutable models that validate themselves and reuse
9
+ already validated children. pytypehint is the schema and validation core of
10
+ [func-to-web](https://offerrall.github.io/func-to-web/).
11
+
12
+ ```python
13
+ from dataclasses import dataclass
14
+ from typing import Annotated
15
+ from pytypehint import Min, struct_of
16
+
17
+ @dataclass
18
+ class Item:
19
+ name: str
20
+ quantity: Annotated[int, Min(1)] = 1
21
+
22
+ schema = struct_of(Item)
23
+ item = schema.build({"name": "Pen"}) # Item(name='Pen', quantity=1)
24
+ schema.build({"name": "Pen", "quantity": 0}) # SchemaValueError: quantity
25
+ ```
26
+
27
+ The full documentation is at https://offerrall.github.io/pytypehint/.
28
+
29
+ ## Documentation
30
+
31
+ - [Overview](https://offerrall.github.io/pytypehint/): immutable models, portable data, field metadata and the five operations.
32
+ - [Immutable models](https://offerrall.github.io/pytypehint/immutable/): the `@immutable` contract, allowed fields, reuse and Python behavior.
33
+ - [Types and API](https://offerrall.github.io/pytypehint/vocabulary/): which hints compile to which shapes, and the public API.
34
+ - [Atoms](https://offerrall.github.io/pytypehint/atoms/): constraints and notation per shape, file names, layering and extras.
35
+ - [Build](https://offerrall.github.io/pytypehint/build/): validating and constructing instances or keyword arguments, union selection and errors.
36
+ - [Resolve](https://offerrall.github.io/pytypehint/resolve/): validated dictionaries with defaults filled at the current level.
37
+ - [Decode](https://offerrall.github.io/pytypehint/decode/): restoring portable values such as dates, enums and tuples before validation.
38
+ - [Encode](https://offerrall.github.io/pytypehint/encode/): writing valid values as portable data, the inverse of decode.
39
+ - [Tuples](https://offerrall.github.io/pytypehint/tuples/): fixed, variadic and empty tuples and the `Tuple` shape.
40
+ - [Defaults](https://offerrall.github.io/pytypehint/defaults/): certification and rematerialization of default values.
41
+ - [Portable contract](https://offerrall.github.io/pytypehint/contract/): the JSON-compatible schema document returned by `to_dict()`.
42
+ - [Limits](https://offerrall.github.io/pytypehint/limits/): rejected definitions, option identity and runtime limits.
43
+ - [Design](https://offerrall.github.io/pytypehint/design/): what the core owns, what consumers own, and why.
@@ -95,7 +95,8 @@ A field default taken from the document round-trips through
95
95
  `schema.build(schema.decode(data))`, subject to the author's
96
96
  [default purity requirements](defaults.md).
97
97
 
98
- To select an option when writing portable values:
98
+ [`encode`](encode.md) writes any valid value in this spelling. To select an
99
+ option when writing portable values by other means:
99
100
 
100
101
  | Slot | Encoding |
101
102
  |---|---|
@@ -16,7 +16,7 @@ booking = schema.build(schema.decode({"day": "2026-08-08"}))
16
16
  assert booking.day == date(2026, 8, 8)
17
17
  ```
18
18
 
19
- The caller parses JSON text. Decode accepts the resulting plain dictionaries,
19
+ [`encode`](encode.md) is the inverse operation. The caller parses JSON text. Decode accepts the resulting plain dictionaries,
20
20
  lists, strings, numbers, booleans and `None`; it does not parse or emit JSON.
21
21
 
22
22
  | Shape | Restoration |
@@ -10,7 +10,7 @@ checks requiring files, networks or application state:
10
10
  | Task | Responsible layer |
11
11
  |---|---|
12
12
  | Define ordinary models | Python dataclasses and type hints |
13
- | Inspect/export schemas, validate values, restore portable types, construct models | pytypehint |
13
+ | Inspect/export schemas, validate values, restore and write portable types, construct models | pytypehint |
14
14
  | Parse JSON, coerce form/CLI input, render controls, collect multiple errors | Consumer |
15
15
  | Call functions, check files, manage persistence/authentication | Application |
16
16
 
@@ -0,0 +1,74 @@
1
+ # Encode
2
+
3
+ `schema.encode(value)` writes a valid value as the portable tree that
4
+ [decode](decode.md) reads. It is the inverse of `decode`:
5
+
6
+ ```python
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ from pytypehint import struct_of
10
+
11
+ @dataclass
12
+ class Booking:
13
+ day: date
14
+ guests: int | float = 2
15
+
16
+ schema = struct_of(Booking)
17
+ booking = Booking(day=date(2026, 8, 8))
18
+ written = schema.encode(booking)
19
+
20
+ assert written == {"day": "2026-08-08", "guests": {"$type": "int", "$value": 2}}
21
+ assert schema.build(schema.decode(written)) == booking
22
+ ```
23
+
24
+ `Struct.encode` takes an exact instance of its dataclass. `Signature.encode`
25
+ takes a dictionary of arguments, such as the one `build` returns, and writes the
26
+ keys it is given; it fills no defaults and rejects unknown keys.
27
+
28
+ The result is a new tree of plain dictionaries, lists, strings, numbers, booleans
29
+ and `None`. The caller serializes it, for example with `json.dumps`. The value is
30
+ never modified, and the tree shares no container with it.
31
+
32
+ | Shape | Written as |
33
+ |---|---|
34
+ | `Date` | `"YYYY-MM-DD"` |
35
+ | `Time` | `"HH:MM:SS"` |
36
+ | `EnumShape` | Member name |
37
+ | `List`, `Tuple` | Array |
38
+ | `Struct` | Object of its fields |
39
+ | Others | The value itself |
40
+
41
+ ## Unions
42
+
43
+ An option is named exactly where its portable spelling collides with another
44
+ option of the same slot, following the [portable contract](contract.md#values-and-defaults):
45
+ dataclass variants carry an inline `$type`, other colliding options use the
46
+ `$type`/`$value` wrapper, and unambiguous options are written bare. These are the
47
+ spellings `to_dict()` uses for defaults.
48
+
49
+ Options sharing a Python type, such as `Annotated[list[str], Min(1)] | list[int]`,
50
+ are told apart by the first option that accepts the value.
51
+
52
+ ## Round trip
53
+
54
+ For a value that passes validation:
55
+
56
+ ```python
57
+ schema.build(schema.decode(schema.encode(value))) == value
58
+ ```
59
+
60
+ The same holds after `json.loads(json.dumps(...))`. Types are preserved: floats
61
+ stay floats, tuples stay tuples and each union value returns to its own option.
62
+
63
+ ## Validation
64
+
65
+ Encode writes; it does not validate. It checks only what it needs in order to
66
+ write: the root type, that each value belongs to an option of its slot, and
67
+ fixed tuple lengths. Those failures raise `SchemaTypeError` or `SchemaValueError`
68
+ with the field names and indexes of the value in `path`.
69
+
70
+ A value outside its limits, such as a number below `Min`, is written faithfully,
71
+ and `build` rejects it when the tree is read back. Time fractions and offsets are
72
+ kept in the text for the same reason. Non-finite floats are written as they are
73
+ and have no standard JSON spelling. Cycles and excessive nesting can raise
74
+ `RecursionError`.
@@ -78,6 +78,6 @@ validation cannot undo their external effects. Use ordinary dataclasses and
78
78
  Model classes and compiled contracts remain registered for the process lifetime;
79
79
  define reusable models rather than generating a new class for each value.
80
80
 
81
- `resolve`, `build`, `decode` and `to_dict` remain available through `struct_of`.
81
+ `resolve`, `build`, `decode`, `encode` and `to_dict` remain available through `struct_of`.
82
82
  Their input rules and full recursive validation are unchanged; the reuse shortcut
83
83
  belongs only to `@immutable` construction.
@@ -61,8 +61,8 @@ see [Immutable models](immutable.md) for the full contract, inheritance and defa
61
61
  ## Portable data and inspection
62
62
 
63
63
  The same models work with the schema API. `decode` restores portable values such
64
- as JSON arrays and discriminated unions, and `to_dict` describes the schema for a
65
- UI or another process:
64
+ as JSON arrays and discriminated unions, `encode` writes them back, and `to_dict`
65
+ describes the schema for a UI or another process:
66
66
 
67
67
  ```python
68
68
  from pytypehint import struct_of
@@ -77,6 +77,7 @@ loaded = schema.build(schema.decode({
77
77
  }))
78
78
 
79
79
  assert loaded == original
80
+ assert schema.build(schema.decode(schema.encode(original))) == original
80
81
  contract = schema.to_dict() # Portable description for a UI or another process
81
82
  ```
82
83
 
@@ -95,11 +96,12 @@ assert size_field.label.value == "Brush size"
95
96
  assert size_shape.max.value == 256
96
97
  ```
97
98
 
98
- ## The four operations
99
+ ## The five operations
99
100
 
100
101
  | Method | Result |
101
102
  |---|---|
102
103
  | [`build(data)`](build.md) | Dataclass instance; `signature_of(fn)` returns kwargs for `fn(**kwargs)` |
103
104
  | [`resolve(data)`](resolve.md) | Validated dictionary with missing defaults filled at this level |
104
105
  | [`decode(data)`](decode.md) | Portable values restored to Python types |
106
+ | [`encode(value)`](encode.md) | A valid value written as portable data |
105
107
  | [`to_dict()`](contract.md) | Portable schema description |
@@ -48,8 +48,8 @@ Everything public is exported from `pytypehint`:
48
48
  `Extra`, `OptionalToggle`;
49
49
  - `MISSING`.
50
50
 
51
- `Struct` and `Signature` expose the [four operations](overview.md#the-four-operations):
52
- `.build(data)`, `.resolve(data)`, `.decode(data)` and `.to_dict()`.
51
+ `Struct` and `Signature` expose the [five operations](overview.md#the-five-operations):
52
+ `.build(data)`, `.resolve(data)`, `.decode(data)`, `.encode(value)` and `.to_dict()`.
53
53
  `Struct.fields` and `Signature.params` contain `Field` objects; `Field.shape`
54
54
  contains the available shapes, and `Field.default` is `MISSING` when no default
55
55
  exists. `Shape.option_id()` gives the discriminator identity.
@@ -13,7 +13,7 @@ from pytypehint.shapes import (
13
13
  )
14
14
  from pytypehint.utils import MISSING
15
15
 
16
- __version__ = "1.2.2"
16
+ __version__ = "1.3.0"
17
17
 
18
18
  __all__ = [
19
19
  "immutable",
@@ -1,12 +1,10 @@
1
1
  from datetime import date, time
2
2
 
3
- from pytypehint.errors import SchemaValueError
4
3
  from pytypehint.shapes import (
5
4
  Bool, Date, EnumShape, Float, Int, List, NoneShape, Str, Time, Tuple,
6
5
  )
7
- from pytypehint.structure import Struct, _TYPE, _VALUE, _portable_options
6
+ from pytypehint.structure import Struct, _encode_options
8
7
  from pytypehint.utils import MISSING
9
- from pytypehint.validation import value_branch
10
8
 
11
9
  _VERSION = 1
12
10
 
@@ -17,43 +15,6 @@ _TYPE_NAMES = {
17
15
  }
18
16
 
19
17
 
20
- def _portable(shapes, value):
21
- shape = value_branch(shapes, value)
22
- if shape is None:
23
- raise SchemaValueError(
24
- f"cannot describe {value!r}: it matches no option of this slot")
25
- written = _written(shape, value)
26
-
27
- if type(shape) is Struct:
28
- if len([s for s in shapes if type(s) is Struct]) > 1:
29
- return {_TYPE: shape.cls.__name__, **written}
30
- return written
31
-
32
- if any(option is shape for option in _portable_options(shapes)):
33
- return {_TYPE: shape.option_id(), _VALUE: written}
34
- return written
35
-
36
-
37
- def _written(shape, value):
38
- kind = type(shape)
39
- if kind is Date:
40
- return value.isoformat()
41
- if kind is Time:
42
- return value.isoformat(timespec="seconds")
43
- if kind is EnumShape:
44
- return value.name
45
- if kind is List:
46
- return [_portable(shape.item, item) for item in value]
47
- if kind is Tuple:
48
- return [_portable(shape._item_at(i), item) for i, item in enumerate(value)]
49
- if kind is Struct:
50
- return {f.name: _portable(f.shape, getattr(value, f.name))
51
- for f in shape.fields}
52
- if kind is Float:
53
- return _as_float(value)
54
- return value
55
-
56
-
57
18
  def _as_float(value):
58
19
  try:
59
20
  restored = float(value)
@@ -234,7 +195,7 @@ def _field_node(f, doc: _Document) -> dict:
234
195
  if f.optional_toggle is not None:
235
196
  node["optional_toggle"] = f.optional_toggle.enabled
236
197
  if f.default is not MISSING:
237
- node["default"] = _portable(f.shape, f.default)
198
+ node["default"] = _encode_options(f.shape, f.default, (f.name, "default"))
238
199
  node["shape"] = _slot(f.shape, doc)
239
200
  return node
240
201
 
@@ -2,7 +2,7 @@ from dataclasses import dataclass
2
2
 
3
3
  from pytypehint.contract import _signature_document
4
4
  from pytypehint.structure import (
5
- Field, _build_kwargs, _decode_fields, _resolve_fields,
5
+ Field, _build_kwargs, _decode_fields, _encode_fields, _resolve_fields,
6
6
  )
7
7
 
8
8
 
@@ -28,6 +28,9 @@ class Signature:
28
28
  def decode(self, kwargs) -> dict:
29
29
  return _decode_fields(self.params, kwargs)
30
30
 
31
+ def encode(self, kwargs) -> dict:
32
+ return _encode_fields(self.params, kwargs, kind="argument")
33
+
31
34
  def resolve(self, kwargs) -> dict:
32
35
  return _resolve_fields(self.params, kwargs, kind="argument")
33
36
 
@@ -57,6 +57,12 @@ class Struct(Shape):
57
57
  def decode(self, data) -> dict:
58
58
  return _decode_fields(self.fields, data)
59
59
 
60
+ def encode(self, instance) -> dict:
61
+ if type(instance) is not self.cls:
62
+ raise SchemaTypeError(
63
+ f"expected {self.cls.__name__}, got {type(instance).__name__}")
64
+ return _encode_shape(self, instance, ())
65
+
60
66
  def resolve(self, data) -> dict:
61
67
  if type(data) is self.cls:
62
68
  raise SchemaTypeError(f"expected dict, got {self.cls.__name__} instance")
@@ -430,6 +436,69 @@ def _decode_fields(fields, data) -> dict:
430
436
  return decoded
431
437
 
432
438
 
439
+ # Encode checks only what routing needs; build validates what decode restores.
440
+ def _encode_options(shapes, value, path):
441
+ shape = value_branch(shapes, value)
442
+ if shape is None:
443
+ try:
444
+ check_options_value(shapes, value)
445
+ except (TypeError, ValueError) as e:
446
+ raise _prefixed(e, path) from e
447
+ raise AssertionError("unrouted value matches an option")
448
+
449
+ written = _encode_shape(shape, value, path)
450
+
451
+ if type(shape) is Struct:
452
+ if len([s for s in shapes if type(s) is Struct]) > 1:
453
+ return {_TYPE: shape.cls.__name__, **written}
454
+ return written
455
+
456
+ if any(option is shape for option in _portable_options(shapes)):
457
+ return {_TYPE: shape.option_id(), _VALUE: written}
458
+ return written
459
+
460
+
461
+ def _encode_shape(shape, value, path):
462
+ kind = type(shape)
463
+ # Keep an invalid fraction or offset in the text for validation to report.
464
+ if kind in (Date, Time):
465
+ return value.isoformat()
466
+ if kind is EnumShape:
467
+ return value.name
468
+ if kind is List:
469
+ return [_encode_options(shape.item, item, (*path, i))
470
+ for i, item in enumerate(value)]
471
+ if kind is Tuple:
472
+ # Length limits are left for build; only a fixed size decides the slots.
473
+ if not shape.variadic and len(value) != len(shape.items):
474
+ raise SchemaValueError(
475
+ f"expected {len(shape.items)} items, got {len(value)}", path)
476
+ return [_encode_options(shape._item_at(i), item, (*path, i))
477
+ for i, item in enumerate(value)]
478
+ if kind is Struct:
479
+ return {f.name: _encode_options(f.shape, getattr(value, f.name), (*path, f.name))
480
+ for f in shape.fields}
481
+ return value
482
+
483
+
484
+ def _encode_fields(fields, data, *, kind: str) -> dict:
485
+ if type(data) is not dict:
486
+ raise SchemaTypeError(f"expected dict, got {type(data).__name__}")
487
+
488
+ invalid_key = next((key for key in data if type(key) is not str), MISSING)
489
+ if invalid_key is not MISSING:
490
+ raise SchemaTypeError(
491
+ f"expected string keys, got {type(invalid_key).__name__}")
492
+
493
+ known = {f.name for f in fields}
494
+ extra = sorted(k for k in data if k not in known)
495
+ if extra:
496
+ raise SchemaTypeError(f"unexpected {kind}(s): {', '.join(extra)}")
497
+
498
+ return {f.name: _encode_options(f.shape, data[f.name], (f.name,))
499
+ for f in fields if f.name in data}
500
+
501
+
433
502
  # Build disables present-value checks after the outer resolve validated them.
434
503
  def _resolve_fields(fields, data, *, kind: str, fill: bool = True,
435
504
  check_present: bool = True) -> dict: