pycodify 0.1.2__tar.gz → 0.1.3__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 (31) hide show
  1. {pycodify-0.1.2 → pycodify-0.1.3}/PKG-INFO +15 -4
  2. {pycodify-0.1.2 → pycodify-0.1.3}/README.md +14 -3
  3. pycodify-0.1.3/docs/api.rst +32 -0
  4. {pycodify-0.1.2 → pycodify-0.1.3}/docs/architecture.rst +53 -11
  5. {pycodify-0.1.2 → pycodify-0.1.3}/docs/conf.py +4 -5
  6. pycodify-0.1.3/docs/index.rst +38 -0
  7. pycodify-0.1.3/docs/usage.rst +226 -0
  8. {pycodify-0.1.2 → pycodify-0.1.3}/pyproject.toml +3 -3
  9. {pycodify-0.1.2 → pycodify-0.1.3}/src/pycodify/__init__.py +1 -1
  10. {pycodify-0.1.2 → pycodify-0.1.3}/src/pycodify/core.py +24 -6
  11. {pycodify-0.1.2 → pycodify-0.1.3}/src/pycodify/formatters.py +7 -0
  12. pycodify-0.1.3/src/pycodify/imports.py +72 -0
  13. {pycodify-0.1.2 → pycodify-0.1.3}/tests/test_core.py +37 -2
  14. pycodify-0.1.3/tests/test_imports.py +53 -0
  15. pycodify-0.1.2/docs/api.rst +0 -106
  16. pycodify-0.1.2/docs/index.rst +0 -72
  17. pycodify-0.1.2/docs/usage.rst +0 -129
  18. pycodify-0.1.2/src/pycodify/imports.py +0 -36
  19. {pycodify-0.1.2 → pycodify-0.1.3}/.github/workflows/ci.yml +0 -0
  20. {pycodify-0.1.2 → pycodify-0.1.3}/.github/workflows/publish.yml +0 -0
  21. {pycodify-0.1.2 → pycodify-0.1.3}/.gitignore +0 -0
  22. {pycodify-0.1.2 → pycodify-0.1.3}/CONTRIBUTING.md +0 -0
  23. {pycodify-0.1.2 → pycodify-0.1.3}/LICENSE +0 -0
  24. {pycodify-0.1.2 → pycodify-0.1.3}/docs/Makefile +0 -0
  25. {pycodify-0.1.2 → pycodify-0.1.3}/paper.bib +0 -0
  26. {pycodify-0.1.2 → pycodify-0.1.3}/paper.md +0 -0
  27. {pycodify-0.1.2 → pycodify-0.1.3}/scripts/release.py +0 -0
  28. {pycodify-0.1.2 → pycodify-0.1.3}/scripts/update_and_release.py +0 -0
  29. {pycodify-0.1.2 → pycodify-0.1.3}/scripts/update_version.py +0 -0
  30. {pycodify-0.1.2 → pycodify-0.1.3}/scripts/verify_release_ready.py +0 -0
  31. {pycodify-0.1.2 → pycodify-0.1.3}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycodify
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Python source code as a serialization format with automatic import resolution
5
5
  Project-URL: Homepage, https://github.com/OpenHCSDev/pycodify
6
6
  Project-URL: Documentation, https://pycodify.readthedocs.io
@@ -49,7 +49,7 @@ class Config:
49
49
  value: int = 42
50
50
 
51
51
  config = Config(name="production", value=100)
52
- code = generate_python_source(Assignment("config", config), clean_mode=True)
52
+ code = generate_python_source(Assignment("config", config))
53
53
  print(code)
54
54
  # Output:
55
55
  # from __main__ import Config
@@ -62,9 +62,12 @@ print(code)
62
62
  |--------|:--------:|:-----------:|:--------:|:---------------:|:-------------:|
63
63
  | pickle | ✗ | ✗ | ✗ | ✓ | ✗ |
64
64
  | JSON/YAML | ✓ | ✓ | ✓ | ✗ | ✓ |
65
- | Python source | ✓ | ✓ | ✓ | ✓ | ✓ |
65
+ | Python source | ✓ | ✓ | ✓ | ✓ | Depends on imported APIs |
66
66
 
67
- Binary formats like `pickle` cannot be diffed, inspected, or edited. Text formats like JSON lose type information. Python source code has all desired properties: it is diffable, inspectable, editable, type-preserving, and cross-version stable.
67
+ Binary formats like `pickle` cannot be diffed, inspected, or edited. Text
68
+ formats like JSON lose type information. Python source is diffable, inspectable,
69
+ editable, and type-preserving, but replay still depends on the imported APIs and
70
+ constructor signatures remaining compatible.
68
71
 
69
72
  ## Features
70
73
 
@@ -76,6 +79,14 @@ Binary formats like `pickle` cannot be diffed, inspected, or edited. Text format
76
79
 
77
80
  ## Documentation
78
81
 
82
+ Explicit mode is the default (``clean_mode=False``), preserving every field for
83
+ reproducibility. Pass ``clean_mode=True`` only when concise, default-eliding
84
+ source is desired.
85
+
86
+ Generated output is executable Python, not a sandboxed data format. Review it
87
+ before execution and never call `exec` on source generated from untrusted
88
+ objects, custom formatters, headers, or edited files.
89
+
79
90
  Full documentation available at [pycodify.readthedocs.io](https://pycodify.readthedocs.io)
80
91
 
81
92
  ## Installation
@@ -14,7 +14,7 @@ class Config:
14
14
  value: int = 42
15
15
 
16
16
  config = Config(name="production", value=100)
17
- code = generate_python_source(Assignment("config", config), clean_mode=True)
17
+ code = generate_python_source(Assignment("config", config))
18
18
  print(code)
19
19
  # Output:
20
20
  # from __main__ import Config
@@ -27,9 +27,12 @@ print(code)
27
27
  |--------|:--------:|:-----------:|:--------:|:---------------:|:-------------:|
28
28
  | pickle | ✗ | ✗ | ✗ | ✓ | ✗ |
29
29
  | JSON/YAML | ✓ | ✓ | ✓ | ✗ | ✓ |
30
- | Python source | ✓ | ✓ | ✓ | ✓ | ✓ |
30
+ | Python source | ✓ | ✓ | ✓ | ✓ | Depends on imported APIs |
31
31
 
32
- Binary formats like `pickle` cannot be diffed, inspected, or edited. Text formats like JSON lose type information. Python source code has all desired properties: it is diffable, inspectable, editable, type-preserving, and cross-version stable.
32
+ Binary formats like `pickle` cannot be diffed, inspected, or edited. Text
33
+ formats like JSON lose type information. Python source is diffable, inspectable,
34
+ editable, and type-preserving, but replay still depends on the imported APIs and
35
+ constructor signatures remaining compatible.
33
36
 
34
37
  ## Features
35
38
 
@@ -41,6 +44,14 @@ Binary formats like `pickle` cannot be diffed, inspected, or edited. Text format
41
44
 
42
45
  ## Documentation
43
46
 
47
+ Explicit mode is the default (``clean_mode=False``), preserving every field for
48
+ reproducibility. Pass ``clean_mode=True`` only when concise, default-eliding
49
+ source is desired.
50
+
51
+ Generated output is executable Python, not a sandboxed data format. Review it
52
+ before execution and never call `exec` on source generated from untrusted
53
+ objects, custom formatters, headers, or edited files.
54
+
44
55
  Full documentation available at [pycodify.readthedocs.io](https://pycodify.readthedocs.io)
45
56
 
46
57
  ## Installation
@@ -0,0 +1,32 @@
1
+ API orientation
2
+ ===============
3
+
4
+ ``Assignment`` and ``CodeBlock``
5
+ Structured top-level source items.
6
+
7
+ ``generate_python_source(obj, header="", clean_mode=False, *, context=None)``
8
+ Generate complete executable source and required imports. A caller-supplied
9
+ ``FormatContext`` is retained across both render passes and owns clean mode.
10
+
11
+ ``to_source(value, context=None)``
12
+ Format one value into a ``SourceFragment``.
13
+
14
+ ``SourceFormatter``
15
+ Nominal extension point. Subclasses implement ``can_format`` and ``format``;
16
+ application-specific formatters register without changing pycodify core.
17
+
18
+ ``resolve_imports``
19
+ Resolve import requirements and aliases for colliding names.
20
+
21
+ ``FormatContext`` and ``SourceFragment``
22
+ Immutable typed formatting state and output records. ``FormatContext`` may
23
+ carry exact-type ``extensions`` and exposes them through ``extension(type)``.
24
+
25
+ The canonical import surface is ``pycodify.__all__``.
26
+
27
+ Public API
28
+ ----------
29
+
30
+ .. automodule:: pycodify
31
+ :members:
32
+ :member-order: bysource
@@ -8,9 +8,12 @@ Generating executable source requires knowing import aliases before emitting cod
8
8
 
9
9
  pycodify solves this with two passes:
10
10
 
11
- 1. **Collection Pass**: Traverse the object, emit code fragments, collect ``(module, name)`` import pairs
12
- 2. **Resolution**: Detect collisions (same name, different modules), generate deterministic aliases
13
- 3. **Regeneration Pass**: Re-traverse with resolved ``name_mappings``, emit final code
11
+ 1. **Collection pass**: Traverse the object, emit code fragments, and collect
12
+ ``(module, name)`` import pairs.
13
+ 2. **Resolution**: ``resolve_imports`` sorts the pairs, detects names exported by
14
+ more than one module, and derives aliases from each module path.
15
+ 3. **Regeneration pass**: Re-traverse with the resolved ``name_mappings`` and
16
+ emit final code using those local names.
14
17
 
15
18
  This is not an optimization—it is structurally necessary. A single-pass algorithm cannot know whether ``Config`` needs aliasing until it has seen all types that might also be named ``Config``.
16
19
 
@@ -45,13 +48,39 @@ Core Components
45
48
  Represents a piece of generated code with its required imports.
46
49
 
47
50
  **FormatContext**
48
- Carries state during formatting: ``name_mappings``, ``visited_objects``, etc.
49
-
50
- **ImportResolver**
51
- Detects collisions and generates deterministic aliases for imports.
52
-
53
- **SourceGenerator**
54
- Orchestrates the two-pass algorithm and produces final executable code.
51
+ Carries indentation depth, ``clean_mode``, the ``(module, name)`` to
52
+ local-name mapping, and caller-owned typed extensions. ``indented()`` returns
53
+ a copied context and does not mutate its caller.
54
+
55
+ **resolve_imports**
56
+ Detects collisions and returns import lines plus the name mapping used by the
57
+ regeneration pass.
58
+
59
+ **generate_python_source**
60
+ Orchestrates collection, resolution, and regeneration to produce a complete
61
+ source string.
62
+
63
+ Immutable typed context extensions
64
+ ----------------------------------
65
+
66
+ ``FormatContext`` is frozen. Its default mappings are read-only mapping proxies,
67
+ and context evolution uses dataclass replacement rather than mutation. A host
68
+ may supply an ``extensions`` mapping keyed by the exact nominal type of each
69
+ rendering policy or state record. A formatter retrieves one value with
70
+ ``context.extension(ExtensionType)``; pycodify does not search base classes,
71
+ string keys, or a fallback chain.
72
+
73
+ ``generate_python_source(..., context=context)`` threads the caller's context
74
+ through collection and regeneration. Import resolution replaces only
75
+ ``name_mappings`` for the second pass, so typed extensions and all other context
76
+ fields survive. When a context is supplied, its ``clean_mode`` is authoritative;
77
+ the separate ``clean_mode=`` argument is used only when pycodify constructs the
78
+ context.
79
+
80
+ Extensions provide formatting context, not formatter dispatch. New value
81
+ families still extend the ``SourceFormatter`` registry, while the exact extension
82
+ type allows that formatter to obtain host-owned rendering information without
83
+ adding host imports or fields to pycodify core.
55
84
 
56
85
  Clean Mode
57
86
  ----------
@@ -64,6 +93,20 @@ Clean mode omits fields matching their default values. This requires:
64
93
 
65
94
  Explicit mode includes all fields for complete reproducibility.
66
95
 
96
+ Stability and trust boundary
97
+ ----------------------------
98
+
99
+ Import ordering and collision aliases are deterministic for a fixed set of
100
+ ``(module, name)`` pairs. The library does not promise that arbitrary object
101
+ graphs produce byte-identical text across runs: container iteration, object
102
+ ``repr`` implementations, custom formatters, and dependency versions can all
103
+ affect output or replay.
104
+
105
+ The output is executable Python. pycodify owns formatting and import resolution,
106
+ not sandboxing or validation of generated programs. Hosts must restrict inputs
107
+ and custom formatters to trusted sources, review persisted code before running
108
+ it, and define their own compatibility policy for imported domain APIs.
109
+
67
110
  Lazy Dataclass Integration
68
111
  ---------------------------
69
112
 
@@ -86,4 +129,3 @@ Module Structure
86
129
  - **core.py**: Main API (``Assignment``, ``generate_python_source``)
87
130
  - **formatters.py**: Built-in formatters (dataclass, enum, primitive types)
88
131
  - **imports.py**: Import resolution and collision handling
89
-
@@ -1,16 +1,17 @@
1
1
  """Sphinx configuration for pycodify."""
2
2
  import os
3
3
  import sys
4
+ from pathlib import Path
4
5
 
5
6
  # Add source to path
6
- sys.path.insert(0, os.path.abspath("../src"))
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
7
8
 
8
9
  # Project information
9
10
  project = "pycodify"
10
11
  copyright = "2024, Tristan Simas"
11
12
  author = "Tristan Simas"
12
- version = "0.1.0"
13
- release = "0.1.0"
13
+ version = "0.1"
14
+ release = "0.1.3"
14
15
 
15
16
  # General configuration
16
17
  extensions = [
@@ -54,7 +55,6 @@ autosummary_generate = True
54
55
  html_theme = "sphinx_rtd_theme"
55
56
  html_theme_options = {
56
57
  "logo_only": False,
57
- "display_version": True,
58
58
  "prev_next_buttons_location": "bottom",
59
59
  "style_external_links": False,
60
60
  "vcs_pageview_mode": "",
@@ -70,4 +70,3 @@ html_favicon = None
70
70
  intersphinx_mapping = {
71
71
  "python": ("https://docs.python.org/3", None),
72
72
  }
73
-
@@ -0,0 +1,38 @@
1
+ pycodify
2
+ ========
3
+
4
+ pycodify serializes Python objects to executable Python source while resolving
5
+ imports and name collisions.
6
+
7
+ Quick start
8
+ -----------
9
+
10
+ .. code-block:: python
11
+
12
+ from dataclasses import dataclass
13
+ from pycodify import Assignment, generate_python_source
14
+
15
+ @dataclass
16
+ class Config:
17
+ name: str = "default"
18
+ value: int = 42
19
+
20
+ config = Config(name="production", value=100)
21
+ code = generate_python_source(Assignment("config", config))
22
+
23
+ Explicit mode is the default (``clean_mode=False``) and includes default-valued
24
+ fields. Pass ``clean_mode=True`` only when default elision is desired.
25
+
26
+ .. toctree::
27
+ :maxdepth: 2
28
+
29
+ usage
30
+ api
31
+ architecture
32
+
33
+ Ownership
34
+ ---------
35
+
36
+ pycodify owns source fragments, imports, formatter registration, collision
37
+ handling, and generic Python formatting. Host packages own their domain
38
+ formatters and round-trip policies.
@@ -0,0 +1,226 @@
1
+ Usage Guide
2
+ ===========
3
+
4
+ Basic Usage
5
+ -----------
6
+
7
+ The simplest way to use pycodify is to generate source code for an object:
8
+
9
+ .. code-block:: python
10
+
11
+ from pycodify import Assignment, generate_python_source
12
+ from dataclasses import dataclass
13
+
14
+ @dataclass
15
+ class ProcessingConfig:
16
+ input_path: str = "/data/input"
17
+ output_path: str = "/data/output"
18
+ num_workers: int = 4
19
+
20
+ config = ProcessingConfig(
21
+ input_path="/data/production",
22
+ num_workers=8
23
+ )
24
+
25
+ code = generate_python_source(
26
+ Assignment("config", config),
27
+ clean_mode=True
28
+ )
29
+ print(code)
30
+
31
+ Clean Mode vs Explicit Mode
32
+ ----------------------------
33
+
34
+ **Clean mode** omits fields that match their default values and must be enabled
35
+ explicitly:
36
+
37
+ .. code-block:: python
38
+
39
+ # Clean mode - concise
40
+ code = generate_python_source(assignment, clean_mode=True)
41
+ # Output: config = ProcessingConfig(input_path='/data/production', num_workers=8)
42
+
43
+ **Explicit mode** includes all fields for complete reproducibility and is the
44
+ default:
45
+
46
+ .. code-block:: python
47
+
48
+ # Explicit mode - complete
49
+ code = generate_python_source(assignment, clean_mode=False)
50
+ # Output: config = ProcessingConfig(
51
+ # input_path='/data/production',
52
+ # output_path='/data/output',
53
+ # num_workers=8
54
+ # )
55
+
56
+ Working with Enums
57
+ ------------------
58
+
59
+ Enums are serialized with their full qualified names:
60
+
61
+ .. code-block:: python
62
+
63
+ from enum import Enum
64
+
65
+ class ImageFormat(Enum):
66
+ JPEG = "jpeg"
67
+ PNG = "png"
68
+ TIFF = "tiff"
69
+
70
+ @dataclass
71
+ class ImageConfig:
72
+ format: ImageFormat = ImageFormat.JPEG
73
+
74
+ config = ImageConfig(format=ImageFormat.PNG)
75
+ code = generate_python_source(Assignment("config", config))
76
+ # Output includes: from __main__ import ImageFormat
77
+ # config = ImageConfig(format=ImageFormat.PNG)
78
+
79
+ Handling Import Collisions
80
+ ---------------------------
81
+
82
+ When multiple modules export the same name, pycodify aliases every colliding
83
+ import. Aliases are derived from the complete module path, with dots replaced
84
+ by underscores:
85
+
86
+ .. code-block:: python
87
+
88
+ # pycodify generates:
89
+ # from package.module_a import Config as Config_package_module_a
90
+ # from package.module_b import Config as Config_package_module_b
91
+ # left = Config_package_module_a(...)
92
+ # right = Config_package_module_b(...)
93
+
94
+ ``resolve_imports`` sorts modules and imported names before emitting them, so a
95
+ given set of import requirements has stable ordering and aliases. Whole-file
96
+ byte-for-byte stability additionally depends on value iteration order, ``repr``
97
+ implementations, and any custom formatters.
98
+
99
+ Formatting one value
100
+ --------------------
101
+
102
+ ``to_source`` returns a ``SourceFragment`` rather than a complete file. Pass a
103
+ ``FormatContext`` when indentation, clean mode, or already-resolved names must
104
+ be controlled explicitly:
105
+
106
+ .. code-block:: python
107
+
108
+ from pycodify import FormatContext, to_source
109
+
110
+ context = FormatContext(indent=1, clean_mode=True)
111
+ fragment = to_source([1, 2], context)
112
+ print(fragment.code)
113
+ print(fragment.imports)
114
+
115
+ ``FormatContext.indented()`` returns a copy with one additional indentation
116
+ level. ``name_mappings`` maps ``(module, imported_name)`` pairs to the local
117
+ names chosen by ``resolve_imports``; callers normally let
118
+ ``generate_python_source`` populate it during the second pass.
119
+
120
+ Typed rendering extensions
121
+ --------------------------
122
+
123
+ A host can attach immutable rendering context without adding host-specific state
124
+ to pycodify. Keys are exact nominal types, and the same context reaches both
125
+ render passes:
126
+
127
+ .. code-block:: python
128
+
129
+ from dataclasses import dataclass
130
+ from types import MappingProxyType
131
+
132
+ from pycodify import (
133
+ Assignment,
134
+ FormatContext,
135
+ SourceFormatter,
136
+ SourceFragment,
137
+ generate_python_source,
138
+ )
139
+
140
+ @dataclass(frozen=True)
141
+ class RenderPolicy:
142
+ marker: str
143
+
144
+ class PolicyValue:
145
+ pass
146
+
147
+ class PolicyValueFormatter(SourceFormatter):
148
+ priority = 200
149
+
150
+ def can_format(self, value):
151
+ return isinstance(value, PolicyValue)
152
+
153
+ def format(self, value, context):
154
+ del value
155
+ policy = context.extension(RenderPolicy)
156
+ if policy is None:
157
+ raise RuntimeError("RenderPolicy is required")
158
+ return SourceFragment(repr(policy.marker))
159
+
160
+ context = FormatContext(
161
+ clean_mode=True,
162
+ extensions=MappingProxyType(
163
+ {RenderPolicy: RenderPolicy(marker="preserved")}
164
+ ),
165
+ )
166
+ source = generate_python_source(
167
+ Assignment("value", PolicyValue()),
168
+ context=context,
169
+ )
170
+ assert source == "value = 'preserved'"
171
+
172
+ ``extension(RenderPolicy)`` does not return a value stored under a base class or
173
+ string key. This exact-type lookup keeps extension ownership explicit. Treat the
174
+ extension mapping as immutable; use a new ``FormatContext`` when the host needs
175
+ different state.
176
+
177
+ Nested Dataclasses
178
+ -------------------
179
+
180
+ Nested dataclasses are properly serialized with all necessary imports:
181
+
182
+ .. code-block:: python
183
+
184
+ from dataclasses import dataclass, field
185
+
186
+ @dataclass
187
+ class DatabaseConfig:
188
+ host: str = "localhost"
189
+ port: int = 5432
190
+
191
+ @dataclass
192
+ class AppConfig:
193
+ database: DatabaseConfig = field(default_factory=DatabaseConfig)
194
+ debug: bool = False
195
+
196
+ config = AppConfig(
197
+ database=DatabaseConfig(host="prod.db.internal"),
198
+ debug=True
199
+ )
200
+
201
+ code = generate_python_source(Assignment("config", config))
202
+ # Generates imports for both DatabaseConfig and AppConfig
203
+
204
+ Executing Generated Code
205
+ ------------------------
206
+
207
+ The generated code is executable Python:
208
+
209
+ .. code-block:: python
210
+
211
+ code = generate_python_source(assignment)
212
+ namespace = {}
213
+ exec(code, namespace)
214
+ recreated_config = namespace["config"]
215
+ assert recreated_config == original_config
216
+
217
+ .. warning::
218
+
219
+ Generated output is executable Python, not a sandboxed data format. Execute
220
+ it only when the input objects, custom formatters, header, and resulting file
221
+ are trusted and have been reviewed. pycodify does not make ``exec`` safe for
222
+ untrusted input.
223
+
224
+ Replay compatibility is owned jointly by the generated imports and the APIs
225
+ they call. Explicit mode records default-valued fields, but neither mode can
226
+ guarantee replay across incompatible module moves or constructor changes.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycodify"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Python source code as a serialization format with automatic import resolution"
9
9
  authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}]
10
10
  license = {text = "MIT"}
@@ -62,11 +62,11 @@ target-version = ["py310", "py311", "py312", "py313"]
62
62
 
63
63
  [tool.ruff]
64
64
  line-length = 100
65
- target-version = "0.1.2"
65
+ target-version = "py310"
66
66
  select = ["E", "F", "I", "N", "W", "UP"]
67
67
 
68
68
  [tool.mypy]
69
- python_version = "0.1.2"
69
+ python_version = "3.10"
70
70
  warn_return_any = true
71
71
  warn_unused_configs = true
72
72
  disallow_untyped_defs = true
@@ -14,7 +14,7 @@ from .imports import resolve_imports
14
14
  # Register default formatters
15
15
  from . import formatters as _formatters # noqa: F401
16
16
 
17
- __version__ = "0.1.2"
17
+ __version__ = "0.1.3"
18
18
 
19
19
  __all__ = [
20
20
  "Assignment",
@@ -2,7 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
4
  from dataclasses import dataclass, field, replace
5
- from typing import Any, ClassVar, Dict, FrozenSet, Iterable, List, Tuple
5
+ from collections.abc import Mapping
6
+ from types import MappingProxyType
7
+ from typing import Any, ClassVar, FrozenSet, Iterable, List, Tuple
6
8
 
7
9
 
8
10
  @dataclass(frozen=True)
@@ -19,13 +21,18 @@ class SourceFragment:
19
21
  )
20
22
 
21
23
 
22
- @dataclass
24
+ @dataclass(frozen=True)
23
25
  class FormatContext:
24
26
  """Context passed through serialization."""
25
27
 
26
28
  indent: int = 0
27
29
  clean_mode: bool = False
28
- name_mappings: Dict[Tuple[str, str], str] = field(default_factory=dict)
30
+ name_mappings: Mapping[Tuple[str, str], str] = field(
31
+ default_factory=lambda: MappingProxyType({})
32
+ )
33
+ extensions: Mapping[type[object], object] = field(
34
+ default_factory=lambda: MappingProxyType({})
35
+ )
29
36
 
30
37
  def indented(self) -> "FormatContext":
31
38
  return replace(self, indent=self.indent + 1)
@@ -34,6 +41,11 @@ class FormatContext:
34
41
  def indent_str(self) -> str:
35
42
  return " " * self.indent
36
43
 
44
+ def extension(self, extension_type: type[Any]) -> Any | None:
45
+ """Return one caller-owned rendering extension by exact nominal type."""
46
+
47
+ return self.extensions.get(extension_type)
48
+
37
49
 
38
50
  @dataclass(frozen=True)
39
51
  class Comment:
@@ -119,16 +131,22 @@ def to_source(value: Any, ctx: FormatContext | None = None) -> SourceFragment:
119
131
  return formatter.format(value, ctx)
120
132
 
121
133
 
122
- def generate_python_source(obj: Any, header: str = "", clean_mode: bool = False) -> str:
134
+ def generate_python_source(
135
+ obj: Any,
136
+ header: str = "",
137
+ clean_mode: bool = False,
138
+ *,
139
+ context: FormatContext | None = None,
140
+ ) -> str:
123
141
  """Generate complete Python source with imports."""
124
142
  from .imports import resolve_imports
125
143
 
126
- ctx = FormatContext(clean_mode=clean_mode)
144
+ ctx = context or FormatContext(clean_mode=clean_mode)
127
145
  fragment = to_source(obj, ctx)
128
146
 
129
147
  import_lines, name_mappings = resolve_imports(fragment.imports)
130
148
 
131
- ctx = FormatContext(clean_mode=clean_mode, name_mappings=name_mappings)
149
+ ctx = replace(ctx, name_mappings=MappingProxyType(name_mappings))
132
150
  fragment = to_source(obj, ctx)
133
151
 
134
152
  code_lines: List[str] = []
@@ -317,6 +317,13 @@ def _format_dataclass_expression(
317
317
  continue
318
318
 
319
319
  if dataclasses.is_dataclass(current_value) and not isinstance(current_value, type):
320
+ formatter = SourceFormatter.get_formatter(current_value)
321
+ if not isinstance(formatter, DataclassFormatter):
322
+ frag = formatter.format(current_value, field_ctx)
323
+ imports |= frag.imports
324
+ lines.append(f"{field_name}={frag.code}")
325
+ continue
326
+
320
327
  nested_code, nested_imports, nested_has_fields = _format_dataclass_expression(
321
328
  current_value,
322
329
  field_ctx,
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import Dict, FrozenSet, Iterable, List, Optional, Tuple
5
+
6
+
7
+ ImportSpec = Tuple[str, Optional[str]]
8
+
9
+
10
+ def _alias_for_collision(name: str, module: str) -> str:
11
+ return f"{name}_{module.replace('.', '_')}"
12
+
13
+
14
+ def _format_import_target(name: str, alias: Optional[str]) -> str:
15
+ if alias is None:
16
+ return name
17
+ return f"{name} as {alias}"
18
+
19
+
20
+ def _format_module_import(module: str, targets: List[ImportSpec]) -> List[str]:
21
+ if len(targets) == 1:
22
+ name, alias = targets[0]
23
+ return [f"from {module} import {_format_import_target(name, alias)}"]
24
+
25
+ lines = [f"from {module} import ("]
26
+ lines.extend(
27
+ f" {_format_import_target(name, alias)},"
28
+ for name, alias in targets
29
+ )
30
+ lines.append(")")
31
+ return lines
32
+
33
+
34
+ def resolve_imports(
35
+ imports: FrozenSet[Tuple[str, str]] | Iterable[Tuple[str, str]],
36
+ ) -> Tuple[List[str], Dict[Tuple[str, str], str]]:
37
+ """Resolve import collisions and generate import lines."""
38
+ import_pairs = {pair for pair in imports if pair and pair[0] and pair[1]}
39
+
40
+ # Filter out builtins and None modules
41
+ filtered = [
42
+ (module, name)
43
+ for module, name in import_pairs
44
+ if module not in (None, "builtins")
45
+ ]
46
+
47
+ name_to_modules = defaultdict(list)
48
+ for module, name in filtered:
49
+ name_to_modules[name].append(module)
50
+
51
+ imports_by_module: Dict[str, List[ImportSpec]] = defaultdict(list)
52
+ name_mappings: Dict[Tuple[str, str], str] = {}
53
+
54
+ for module, name in sorted(filtered):
55
+ if len(name_to_modules[name]) > 1:
56
+ alias = _alias_for_collision(name, module)
57
+ imports_by_module[module].append((name, alias))
58
+ name_mappings[(module, name)] = alias
59
+ else:
60
+ imports_by_module[module].append((name, None))
61
+ name_mappings[(module, name)] = name
62
+
63
+ import_lines: List[str] = []
64
+ for module in sorted(imports_by_module):
65
+ import_lines.extend(
66
+ _format_module_import(
67
+ module,
68
+ sorted(imports_by_module[module], key=lambda target: target[0]),
69
+ )
70
+ )
71
+
72
+ return import_lines, name_mappings
@@ -3,7 +3,15 @@
3
3
  import pytest
4
4
  from dataclasses import dataclass
5
5
  from enum import Enum
6
- from pycodify import Assignment, generate_python_source
6
+ from types import MappingProxyType
7
+
8
+ from pycodify import (
9
+ Assignment,
10
+ FormatContext,
11
+ SourceFormatter,
12
+ SourceFragment,
13
+ generate_python_source,
14
+ )
7
15
 
8
16
 
9
17
  class Color(Enum):
@@ -21,6 +29,22 @@ class SimpleConfig:
21
29
  enabled: bool = True
22
30
 
23
31
 
32
+ class ExtensionProbe:
33
+ pass
34
+
35
+
36
+ class ExtensionProbeFormatter(SourceFormatter):
37
+ priority = 200
38
+
39
+ def can_format(self, value):
40
+ return isinstance(value, ExtensionProbe)
41
+
42
+ def format(self, value, context):
43
+ del value
44
+ marker = context.extension(ExtensionProbe)
45
+ return SourceFragment(repr(marker), frozenset())
46
+
47
+
24
48
  class TestBasicGeneration:
25
49
  """Test basic source code generation."""
26
50
 
@@ -77,6 +101,18 @@ class TestBasicGeneration:
77
101
  except Exception as e:
78
102
  pytest.fail(f"Generated source is not executable: {e}\n{source}")
79
103
 
104
+ def test_context_extensions_survive_both_render_passes(self):
105
+ context = FormatContext(
106
+ extensions=MappingProxyType({ExtensionProbe: "preserved"})
107
+ )
108
+
109
+ source = generate_python_source(
110
+ Assignment("value", ExtensionProbe()),
111
+ context=context,
112
+ )
113
+
114
+ assert source == "value = 'preserved'"
115
+
80
116
 
81
117
  class TestImportCollisions:
82
118
  """Test handling of import name collisions."""
@@ -91,4 +127,3 @@ class TestImportCollisions:
91
127
 
92
128
  assert source is not None
93
129
  assert len(source) > 0
94
-
@@ -0,0 +1,53 @@
1
+ """Tests for pycodify import rendering."""
2
+
3
+ from pycodify.imports import resolve_imports
4
+
5
+
6
+ def test_resolve_imports_groups_multiple_names_from_same_module():
7
+ import_lines, name_mappings = resolve_imports(
8
+ frozenset(
9
+ {
10
+ ("example.module", "Beta"),
11
+ ("example.module", "Alpha"),
12
+ ("other.module", "Gamma"),
13
+ }
14
+ )
15
+ )
16
+
17
+ assert import_lines == [
18
+ "from example.module import (",
19
+ " Alpha,",
20
+ " Beta,",
21
+ ")",
22
+ "from other.module import Gamma",
23
+ ]
24
+ assert name_mappings == {
25
+ ("example.module", "Alpha"): "Alpha",
26
+ ("example.module", "Beta"): "Beta",
27
+ ("other.module", "Gamma"): "Gamma",
28
+ }
29
+
30
+
31
+ def test_resolve_imports_groups_alias_collisions_with_module_imports():
32
+ import_lines, name_mappings = resolve_imports(
33
+ frozenset(
34
+ {
35
+ ("alpha.sources", "Config"),
36
+ ("beta.sources", "Config"),
37
+ ("alpha.sources", "Pipeline"),
38
+ }
39
+ )
40
+ )
41
+
42
+ assert import_lines == [
43
+ "from alpha.sources import (",
44
+ " Config as Config_alpha_sources,",
45
+ " Pipeline,",
46
+ ")",
47
+ "from beta.sources import Config as Config_beta_sources",
48
+ ]
49
+ assert name_mappings == {
50
+ ("alpha.sources", "Config"): "Config_alpha_sources",
51
+ ("alpha.sources", "Pipeline"): "Pipeline",
52
+ ("beta.sources", "Config"): "Config_beta_sources",
53
+ }
@@ -1,106 +0,0 @@
1
- API Reference
2
- =============
3
-
4
- Core API
5
- --------
6
-
7
- .. automodule:: pycodify.core
8
- :members:
9
- :undoc-members:
10
- :show-inheritance:
11
-
12
- Formatters
13
- ----------
14
-
15
- .. automodule:: pycodify.formatters
16
- :members:
17
- :undoc-members:
18
- :show-inheritance:
19
-
20
- Import Resolution
21
- -----------------
22
-
23
- .. automodule:: pycodify.imports
24
- :members:
25
- :undoc-members:
26
- :show-inheritance:
27
-
28
- Main Functions
29
- --------------
30
-
31
- .. py:function:: generate_python_source(assignment: Assignment, clean_mode: bool = True) -> str
32
-
33
- Generate executable Python source code from an object.
34
-
35
- :param assignment: Assignment object specifying variable name and value
36
- :type assignment: Assignment
37
- :param clean_mode: If True, omit fields matching defaults. If False, include all fields.
38
- :type clean_mode: bool
39
- :return: Executable Python source code with imports
40
- :rtype: str
41
-
42
- **Example:**
43
-
44
- .. code-block:: python
45
-
46
- from pycodify import Assignment, generate_python_source
47
- from dataclasses import dataclass
48
-
49
- @dataclass
50
- class Config:
51
- name: str = "default"
52
- value: int = 42
53
-
54
- config = Config(name="production", value=100)
55
- code = generate_python_source(Assignment("config", config))
56
- print(code)
57
-
58
- .. py:class:: Assignment
59
-
60
- Represents a variable assignment for code generation.
61
-
62
- .. py:method:: __init__(name: str, value: Any)
63
-
64
- Create an assignment.
65
-
66
- :param name: Variable name
67
- :type name: str
68
- :param value: Value to serialize
69
- :type value: Any
70
-
71
- .. py:class:: SourceFormatter
72
-
73
- Base class for custom formatters.
74
-
75
- .. py:method:: can_format(value: Any) -> bool
76
-
77
- Check if this formatter can handle the value.
78
-
79
- :param value: Value to check
80
- :type value: Any
81
- :return: True if this formatter can format the value
82
- :rtype: bool
83
-
84
- .. py:method:: format(value: Any, context: FormatContext) -> SourceFragment
85
-
86
- Format a value to source code.
87
-
88
- :param value: Value to format
89
- :type value: Any
90
- :param context: Formatting context with state
91
- :type context: FormatContext
92
- :return: Source code fragment with imports
93
- :rtype: SourceFragment
94
-
95
- .. py:class:: SourceFragment
96
-
97
- Represents generated source code with its imports.
98
-
99
- .. py:attribute:: code
100
-
101
- The generated Python source code.
102
-
103
- .. py:attribute:: imports
104
-
105
- Set of (module, name) tuples required for the code.
106
-
@@ -1,72 +0,0 @@
1
- pycodify Documentation
2
- ======================
3
-
4
- **pycodify** serializes Python objects to executable Python source code with automatic import resolution.
5
-
6
- The key insight: **Python source code is a serialization format.** Rather than inventing a format and writing loaders, pycodify emits code that Python itself interprets. The import system becomes the deserializer.
7
-
8
- Quick Start
9
- -----------
10
-
11
- .. code-block:: python
12
-
13
- from pycodify import Assignment, generate_python_source
14
- from dataclasses import dataclass
15
-
16
- @dataclass
17
- class Config:
18
- name: str = "default"
19
- value: int = 42
20
-
21
- config = Config(name="production", value=100)
22
- code = generate_python_source(Assignment("config", config), clean_mode=True)
23
- print(code)
24
- # Output:
25
- # from __main__ import Config
26
- # config = Config(name='production', value=100)
27
-
28
- Features
29
- --------
30
-
31
- - **Complete Executable Source**: Generates imports + code, not just expressions
32
- - **Type-Preserving**: Enums, Paths, callables serialize as themselves
33
- - **Collision Handling**: Automatic aliasing for name collisions across modules
34
- - **Clean Mode**: Omit fields matching defaults for concise output
35
- - **Extensible**: Register custom formatters for domain-specific types
36
-
37
- Why Python Source?
38
- ------------------
39
-
40
- | Format | Diffable | Inspectable | Editable | Type-preserving | Cross-version |
41
- |--------|:--------:|:-----------:|:--------:|:---------------:|:-------------:|
42
- | pickle | ✗ | ✗ | ✗ | ✓ | ✗ |
43
- | JSON/YAML | ✓ | ✓ | ✓ | ✗ | ✓ |
44
- | Python source | ✓ | ✓ | ✓ | ✓ | ✓ |
45
-
46
- Contents
47
- --------
48
-
49
- .. toctree::
50
- :maxdepth: 2
51
-
52
- usage
53
- api
54
- architecture
55
-
56
- API Reference
57
- -------------
58
-
59
- .. autosummary::
60
- :toctree: _autosummary
61
-
62
- pycodify.core
63
- pycodify.formatters
64
- pycodify.imports
65
-
66
- Indices and Tables
67
- ------------------
68
-
69
- * :ref:`genindex`
70
- * :ref:`modindex`
71
- * :ref:`search`
72
-
@@ -1,129 +0,0 @@
1
- Usage Guide
2
- ===========
3
-
4
- Basic Usage
5
- -----------
6
-
7
- The simplest way to use pycodify is to generate source code for an object:
8
-
9
- .. code-block:: python
10
-
11
- from pycodify import Assignment, generate_python_source
12
- from dataclasses import dataclass
13
-
14
- @dataclass
15
- class ProcessingConfig:
16
- input_path: str = "/data/input"
17
- output_path: str = "/data/output"
18
- num_workers: int = 4
19
-
20
- config = ProcessingConfig(
21
- input_path="/data/production",
22
- num_workers=8
23
- )
24
-
25
- code = generate_python_source(
26
- Assignment("config", config),
27
- clean_mode=True
28
- )
29
- print(code)
30
-
31
- Clean Mode vs Explicit Mode
32
- ----------------------------
33
-
34
- **Clean mode** (default) omits fields that match their default values:
35
-
36
- .. code-block:: python
37
-
38
- # Clean mode - concise
39
- code = generate_python_source(assignment, clean_mode=True)
40
- # Output: config = ProcessingConfig(input_path='/data/production', num_workers=8)
41
-
42
- **Explicit mode** includes all fields for complete reproducibility:
43
-
44
- .. code-block:: python
45
-
46
- # Explicit mode - complete
47
- code = generate_python_source(assignment, clean_mode=False)
48
- # Output: config = ProcessingConfig(
49
- # input_path='/data/production',
50
- # output_path='/data/output',
51
- # num_workers=8
52
- # )
53
-
54
- Working with Enums
55
- ------------------
56
-
57
- Enums are serialized with their full qualified names:
58
-
59
- .. code-block:: python
60
-
61
- from enum import Enum
62
-
63
- class ImageFormat(Enum):
64
- JPEG = "jpeg"
65
- PNG = "png"
66
- TIFF = "tiff"
67
-
68
- @dataclass
69
- class ImageConfig:
70
- format: ImageFormat = ImageFormat.JPEG
71
-
72
- config = ImageConfig(format=ImageFormat.PNG)
73
- code = generate_python_source(Assignment("config", config))
74
- # Output includes: from __main__ import ImageFormat
75
- # config = ImageConfig(format=ImageFormat.PNG)
76
-
77
- Handling Import Collisions
78
- ---------------------------
79
-
80
- When multiple modules have classes with the same name, pycodify automatically aliases them:
81
-
82
- .. code-block:: python
83
-
84
- from module_a import Config as ConfigA
85
- from module_b import Config as ConfigB
86
-
87
- # pycodify detects the collision and generates:
88
- # from module_a import Config as Config_1
89
- # from module_b import Config as Config_2
90
- # obj = Config_1(...)
91
-
92
- Nested Dataclasses
93
- -------------------
94
-
95
- Nested dataclasses are properly serialized with all necessary imports:
96
-
97
- .. code-block:: python
98
-
99
- @dataclass
100
- class DatabaseConfig:
101
- host: str = "localhost"
102
- port: int = 5432
103
-
104
- @dataclass
105
- class AppConfig:
106
- database: DatabaseConfig = DatabaseConfig()
107
- debug: bool = False
108
-
109
- config = AppConfig(
110
- database=DatabaseConfig(host="prod.db.internal"),
111
- debug=True
112
- )
113
-
114
- code = generate_python_source(Assignment("config", config))
115
- # Generates imports for both DatabaseConfig and AppConfig
116
-
117
- Executing Generated Code
118
- ------------------------
119
-
120
- The generated code is executable Python:
121
-
122
- .. code-block:: python
123
-
124
- code = generate_python_source(assignment)
125
- namespace = {}
126
- exec(code, namespace)
127
- recreated_config = namespace["config"]
128
- assert recreated_config == original_config
129
-
@@ -1,36 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from collections import defaultdict
4
- from typing import Dict, FrozenSet, Iterable, List, Tuple
5
-
6
-
7
- def resolve_imports(
8
- imports: FrozenSet[Tuple[str, str]] | Iterable[Tuple[str, str]],
9
- ) -> Tuple[List[str], Dict[Tuple[str, str], str]]:
10
- """Resolve import collisions and generate import lines."""
11
- import_pairs = {pair for pair in imports if pair and pair[0] and pair[1]}
12
-
13
- # Filter out builtins and None modules
14
- filtered = [
15
- (module, name)
16
- for module, name in import_pairs
17
- if module not in (None, "builtins")
18
- ]
19
-
20
- name_to_modules = defaultdict(list)
21
- for module, name in filtered:
22
- name_to_modules[name].append(module)
23
-
24
- import_lines: List[str] = []
25
- name_mappings: Dict[Tuple[str, str], str] = {}
26
-
27
- for module, name in sorted(filtered):
28
- if len(name_to_modules[name]) > 1:
29
- alias = f"{name}_{module.split('.')[-1]}"
30
- import_lines.append(f"from {module} import {name} as {alias}")
31
- name_mappings[(module, name)] = alias
32
- else:
33
- import_lines.append(f"from {module} import {name}")
34
- name_mappings[(module, name)] = name
35
-
36
- return import_lines, name_mappings
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes