pycodify 0.1.3__tar.gz → 0.2.1__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 (34) hide show
  1. pycodify-0.2.1/.readthedocs.yaml +17 -0
  2. {pycodify-0.1.3 → pycodify-0.2.1}/PKG-INFO +2 -2
  3. pycodify-0.2.1/docs/api.rst +41 -0
  4. {pycodify-0.1.3 → pycodify-0.2.1}/docs/architecture.rst +37 -44
  5. {pycodify-0.1.3 → pycodify-0.2.1}/docs/conf.py +4 -3
  6. {pycodify-0.1.3 → pycodify-0.2.1}/docs/usage.rst +16 -41
  7. {pycodify-0.1.3 → pycodify-0.2.1}/pyproject.toml +1 -1
  8. {pycodify-0.1.3 → pycodify-0.2.1}/src/pycodify/__init__.py +15 -4
  9. {pycodify-0.1.3 → pycodify-0.2.1}/src/pycodify/core.py +75 -28
  10. pycodify-0.2.1/src/pycodify/documents.py +65 -0
  11. {pycodify-0.1.3 → pycodify-0.2.1}/src/pycodify/formatters.py +106 -98
  12. {pycodify-0.1.3 → pycodify-0.2.1}/src/pycodify/imports.py +11 -19
  13. pycodify-0.2.1/src/pycodify/paths.py +148 -0
  14. {pycodify-0.1.3 → pycodify-0.2.1}/tests/test_core.py +24 -26
  15. pycodify-0.2.1/tests/test_documents.py +45 -0
  16. pycodify-0.2.1/tests/test_paths.py +115 -0
  17. pycodify-0.2.1/tests/test_round_trip.py +147 -0
  18. pycodify-0.1.3/docs/api.rst +0 -32
  19. {pycodify-0.1.3 → pycodify-0.2.1}/.github/workflows/ci.yml +0 -0
  20. {pycodify-0.1.3 → pycodify-0.2.1}/.github/workflows/publish.yml +0 -0
  21. {pycodify-0.1.3 → pycodify-0.2.1}/.gitignore +0 -0
  22. {pycodify-0.1.3 → pycodify-0.2.1}/CONTRIBUTING.md +0 -0
  23. {pycodify-0.1.3 → pycodify-0.2.1}/LICENSE +0 -0
  24. {pycodify-0.1.3 → pycodify-0.2.1}/README.md +0 -0
  25. {pycodify-0.1.3 → pycodify-0.2.1}/docs/Makefile +0 -0
  26. {pycodify-0.1.3 → pycodify-0.2.1}/docs/index.rst +0 -0
  27. {pycodify-0.1.3 → pycodify-0.2.1}/paper.bib +0 -0
  28. {pycodify-0.1.3 → pycodify-0.2.1}/paper.md +0 -0
  29. {pycodify-0.1.3 → pycodify-0.2.1}/scripts/release.py +0 -0
  30. {pycodify-0.1.3 → pycodify-0.2.1}/scripts/update_and_release.py +0 -0
  31. {pycodify-0.1.3 → pycodify-0.2.1}/scripts/update_version.py +0 -0
  32. {pycodify-0.1.3 → pycodify-0.2.1}/scripts/verify_release_ready.py +0 -0
  33. {pycodify-0.1.3 → pycodify-0.2.1}/tests/__init__.py +0 -0
  34. {pycodify-0.1.3 → pycodify-0.2.1}/tests/test_imports.py +0 -0
@@ -0,0 +1,17 @@
1
+ version: 2
2
+
3
+ build:
4
+ os: ubuntu-22.04
5
+ tools:
6
+ python: "3.12"
7
+
8
+ sphinx:
9
+ configuration: docs/conf.py
10
+ fail_on_warning: true
11
+
12
+ python:
13
+ install:
14
+ - method: pip
15
+ path: .
16
+ extra_requirements:
17
+ - docs
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pycodify
3
- Version: 0.1.3
3
+ Version: 0.2.1
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
@@ -0,0 +1,41 @@
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)``
8
+ Generate complete executable source and required imports, with shared and
9
+ repeated absolute paths bound once at the top.
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 formatting state and output records.
23
+
24
+ ``imported_name(module, qualname, context)``
25
+ Spell an importable object by its qualified name, honouring collision aliases.
26
+
27
+ ``PythonSourceLiteral``
28
+ Base for values that spell their own source through ``source_fragment``.
29
+
30
+ ``PathRendering``, ``PathCollector``, ``PathFactoring``, ``PathConstructor``
31
+ How a render pass spells paths: plainly, while recording, or through the
32
+ document's path bindings.
33
+
34
+ The canonical import surface is ``pycodify.__all__``.
35
+
36
+ Public API
37
+ ----------
38
+
39
+ .. automodule:: pycodify
40
+ :members:
41
+ :member-order: bysource
@@ -6,7 +6,8 @@ Two-Pass Algorithm
6
6
 
7
7
  Generating executable source requires knowing import aliases before emitting code. But aliases depend on detecting collisions, which requires visiting all types first. This creates a dependency: code generation requires alias resolution, but alias resolution requires traversing the object graph.
8
8
 
9
- pycodify solves this with two passes:
9
+ pycodify solves this with two passes, preceded by a path-discovery pass
10
+ (see *Path factoring* below):
10
11
 
11
12
  1. **Collection pass**: Traverse the object, emit code fragments, and collect
12
13
  ``(module, name)`` import pairs.
@@ -31,12 +32,26 @@ Each type maps to a ``SourceFormatter`` that emits a ``SourceFragment(code, impo
31
32
  return isinstance(value, Enum)
32
33
 
33
34
  def format(self, value: Enum, context: FormatContext) -> SourceFragment:
34
- cls = value.__class__
35
- import_pair = (cls.__module__, cls.__name__)
36
- name = context.name_mappings.get(import_pair, cls.__name__)
37
- return SourceFragment(f"{name}.{value.name}", frozenset([import_pair]))
35
+ enum_type = imported_name(type(value).__module__, type(value).__qualname__, context)
36
+ return SourceFragment(f"{enum_type.code}.{value.name}", enum_type.imports)
38
37
 
39
- Priority-based dispatch selects the most specific formatter. Domain extensions add formatters without modifying core code.
38
+ Priority-based dispatch selects the most specific formatter. Domain extensions add formatters without modifying core code. A value no formatter accepts raises ``TypeError``: pycodify never emits ``repr`` text it cannot load back.
39
+
40
+ Qualified names
41
+ ---------------
42
+
43
+ ``imported_name(module, qualname, context)`` is the one rule for spelling an
44
+ importable object. It imports the root of ``__qualname__`` and spells the rest as
45
+ attribute access, so ``Outer.Mode.FAST`` imports ``Outer``. Enum members,
46
+ classes, functions, class-bound methods, named tuples and dataclass constructors
47
+ all use it. Objects defined inside a function raise ``TypeError``.
48
+
49
+ Values that own their spelling
50
+ ------------------------------
51
+
52
+ A value whose source is not its constructor subclasses ``PythonSourceLiteral``
53
+ and implements ``source_fragment(context)``, typically building on
54
+ ``imported_name`` so collision aliases apply.
40
55
 
41
56
  Core Components
42
57
  ---------------
@@ -49,7 +64,7 @@ Core Components
49
64
 
50
65
  **FormatContext**
51
66
  Carries indentation depth, ``clean_mode``, the ``(module, name)`` to
52
- local-name mapping, and caller-owned typed extensions. ``indented()`` returns
67
+ local-name mapping, and the pass's ``PathRendering``. ``indented()`` returns
53
68
  a copied context and does not mutate its caller.
54
69
 
55
70
  **resolve_imports**
@@ -60,27 +75,17 @@ Core Components
60
75
  Orchestrates collection, resolution, and regeneration to produce a complete
61
76
  source string.
62
77
 
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.
78
+ Path factoring
79
+ --------------
79
80
 
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.
81
+ ``generate_python_source`` first renders the document with a ``PathCollector``
82
+ on ``FormatContext.paths`` to record every ``pathlib.Path`` value. A
83
+ ``PathFactoring`` plan then binds each directory shared by two or more absolute
84
+ paths (``path_root = Path('/data/screen')``) and each repeated absolute path
85
+ (``path_1 = path_root / 'plate_A'``) once, at the top of the document, and the
86
+ two rendering passes spell those paths through the bindings. Relative paths and
87
+ strings are never factored. ``to_source`` alone uses the base ``PathRendering``,
88
+ which spells each path as ``Path('...')``.
84
89
 
85
90
  Clean Mode
86
91
  ----------
@@ -107,25 +112,13 @@ not sandboxing or validation of generated programs. Hosts must restrict inputs
107
112
  and custom formatters to trusted sources, review persisted code before running
108
113
  it, and define their own compatibility policy for imported domain APIs.
109
114
 
110
- Lazy Dataclass Integration
111
- ---------------------------
112
-
113
- For dataclasses with ``__getattribute__`` interception (used for hierarchical config inheritance), formatters use ``object.__getattribute__`` to access raw field values without triggering lazy resolution:
114
-
115
- .. code-block:: python
116
-
117
- # In DataclassFormatter
118
- if hasattr(instance, "_resolve_field_value"):
119
- # Bypass __getattribute__ to get raw None vs concrete value
120
- current_value = object.__getattribute__(instance, field_name)
121
- else:
122
- current_value = getattr(instance, field_name)
123
-
124
- This distinguishes explicitly-set values from inherited ones during serialization.
125
-
126
115
  Module Structure
127
116
  ----------------
128
117
 
129
118
  - **core.py**: Main API (``Assignment``, ``generate_python_source``)
130
- - **formatters.py**: Built-in formatters (dataclass, enum, primitive types)
119
+ - **formatters.py**: Built-in formatters (dataclass, enum, class, callable, primitive types)
120
+ - **paths.py**: Document-level path factoring
121
+
122
+ Lazy configuration dataclasses (ObjectState) are formatted by
123
+ ``objectstate.codegen``, which registers its own formatter.
131
124
  - **imports.py**: Import resolution and collision handling
@@ -1,6 +1,7 @@
1
1
  """Sphinx configuration for pycodify."""
2
- import os
2
+
3
3
  import sys
4
+ from importlib import import_module
4
5
  from pathlib import Path
5
6
 
6
7
  # Add source to path
@@ -10,8 +11,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
11
  project = "pycodify"
11
12
  copyright = "2024, Tristan Simas"
12
13
  author = "Tristan Simas"
13
- version = "0.1"
14
- release = "0.1.3"
14
+ release = import_module("pycodify").__version__
15
+ version = ".".join(release.split(".")[:2])
15
16
 
16
17
  # General configuration
17
18
  extensions = [
@@ -117,62 +117,37 @@ level. ``name_mappings`` maps ``(module, imported_name)`` pairs to the local
117
117
  names chosen by ``resolve_imports``; callers normally let
118
118
  ``generate_python_source`` populate it during the second pass.
119
119
 
120
- Typed rendering extensions
121
- --------------------------
120
+ Values that own their source
121
+ ----------------------------
122
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:
123
+ A value whose source is not its dataclass constructor implements
124
+ ``PythonSourceLiteral``:
126
125
 
127
126
  .. code-block:: python
128
127
 
129
128
  from dataclasses import dataclass
130
- from types import MappingProxyType
131
129
 
132
130
  from pycodify import (
133
131
  Assignment,
134
132
  FormatContext,
135
- SourceFormatter,
133
+ PythonSourceLiteral,
136
134
  SourceFragment,
137
135
  generate_python_source,
136
+ imported_name,
138
137
  )
139
138
 
140
139
  @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'"
140
+ class RegistryLookup(PythonSourceLiteral):
141
+ key: str
142
+
143
+ def source_fragment(self, context: FormatContext) -> SourceFragment:
144
+ lookup = imported_name("myapp.registry", "get_function", context)
145
+ return SourceFragment(f"{lookup.code}({self.key!r})", lookup.imports)
171
146
 
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.
147
+ source = generate_python_source(Assignment("func", RegistryLookup("gauss")))
148
+ # from myapp.registry import get_function
149
+ #
150
+ # func = get_function('gauss')
176
151
 
177
152
  Nested Dataclasses
178
153
  -------------------
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycodify"
7
- version = "0.1.3"
7
+ version = "0.2.1"
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"}
@@ -1,20 +1,24 @@
1
+ # Register default formatters
2
+ from . import formatters as _formatters # noqa: F401
1
3
  from .core import (
2
4
  Assignment,
3
5
  BlankLine,
4
6
  CodeBlock,
5
7
  Comment,
6
8
  FormatContext,
9
+ PathRendering,
10
+ PythonSourceLiteral,
7
11
  SourceFormatter,
8
12
  SourceFragment,
9
13
  generate_python_source,
14
+ imported_name,
10
15
  to_source,
11
16
  )
17
+ from .documents import SourceDocument
12
18
  from .imports import resolve_imports
19
+ from .paths import PathCollector, PathConstructor, PathFactoring
13
20
 
14
- # Register default formatters
15
- from . import formatters as _formatters # noqa: F401
16
-
17
- __version__ = "0.1.3"
21
+ __version__ = "0.2.1"
18
22
 
19
23
  __all__ = [
20
24
  "Assignment",
@@ -22,9 +26,16 @@ __all__ = [
22
26
  "CodeBlock",
23
27
  "Comment",
24
28
  "FormatContext",
29
+ "PathCollector",
30
+ "PathConstructor",
31
+ "PathFactoring",
32
+ "PathRendering",
33
+ "PythonSourceLiteral",
34
+ "SourceDocument",
25
35
  "SourceFormatter",
26
36
  "SourceFragment",
27
37
  "generate_python_source",
38
+ "imported_name",
28
39
  "resolve_imports",
29
40
  "to_source",
30
41
  ]
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import ABC, abstractmethod
4
+ from collections.abc import Iterable, Mapping
4
5
  from dataclasses import dataclass, field, replace
5
- from collections.abc import Mapping
6
+ from pathlib import Path
6
7
  from types import MappingProxyType
7
- from typing import Any, ClassVar, FrozenSet, Iterable, List, Tuple
8
+ from typing import Any, ClassVar
8
9
 
9
10
 
10
11
  @dataclass(frozen=True)
@@ -12,39 +13,78 @@ class SourceFragment:
12
13
  """Result of serializing a value to Python source."""
13
14
 
14
15
  code: str
15
- imports: FrozenSet[Tuple[str, str]] = frozenset()
16
+ imports: frozenset[tuple[str, str]] = frozenset()
16
17
 
17
- def __add__(self, other: "SourceFragment") -> "SourceFragment":
18
+ def __add__(self, other: SourceFragment) -> SourceFragment:
18
19
  return SourceFragment(
19
20
  code=self.code + other.code,
20
21
  imports=self.imports | other.imports,
21
22
  )
22
23
 
23
24
 
25
+ class PathRendering:
26
+ """How one render pass spells :class:`pathlib.Path` values.
27
+
28
+ The base spells every path as its own constructor call. Subclasses in
29
+ :mod:`pycodify.paths` record or substitute paths for document-level
30
+ factoring.
31
+ """
32
+
33
+ def path_source(self, value: Path, context: FormatContext) -> SourceFragment:
34
+ constructor = imported_name("pathlib", "Path", context)
35
+ return SourceFragment(
36
+ f"{constructor.code}({str(value)!r})",
37
+ constructor.imports,
38
+ )
39
+
40
+
24
41
  @dataclass(frozen=True)
25
42
  class FormatContext:
26
43
  """Context passed through serialization."""
27
44
 
28
45
  indent: int = 0
29
46
  clean_mode: bool = False
30
- name_mappings: Mapping[Tuple[str, str], str] = field(
31
- default_factory=lambda: MappingProxyType({})
32
- )
33
- extensions: Mapping[type[object], object] = field(
47
+ name_mappings: Mapping[tuple[str, str], str] = field(
34
48
  default_factory=lambda: MappingProxyType({})
35
49
  )
50
+ paths: PathRendering = field(default_factory=PathRendering)
36
51
 
37
- def indented(self) -> "FormatContext":
52
+ def indented(self) -> FormatContext:
38
53
  return replace(self, indent=self.indent + 1)
39
54
 
40
55
  @property
41
56
  def indent_str(self) -> str:
42
57
  return " " * self.indent
43
58
 
44
- def extension(self, extension_type: type[Any]) -> Any | None:
45
- """Return one caller-owned rendering extension by exact nominal type."""
46
59
 
47
- return self.extensions.get(extension_type)
60
+ def imported_name(module: str, qualname: str, context: FormatContext) -> SourceFragment:
61
+ """Spell an importable object by its module and ``__qualname__``.
62
+
63
+ The root of the qualified name is imported (under its collision alias, if
64
+ any) and the rest is spelled as attribute access, so nested classes, enums
65
+ and functions load back as themselves. Objects defined inside a function
66
+ have no import path and are rejected.
67
+ """
68
+
69
+ if "<locals>" in qualname:
70
+ raise TypeError(
71
+ f"{module}.{qualname} is defined inside a function and cannot be "
72
+ "imported by generated source."
73
+ )
74
+ root, dot, nested = qualname.partition(".")
75
+ if module == "builtins":
76
+ return SourceFragment(qualname)
77
+ import_pair = (module, root)
78
+ name = context.name_mappings.get(import_pair, root)
79
+ return SourceFragment(f"{name}{dot}{nested}", frozenset({import_pair}))
80
+
81
+
82
+ class PythonSourceLiteral(ABC):
83
+ """A value that spells its own Python source expression."""
84
+
85
+ @abstractmethod
86
+ def source_fragment(self, context: FormatContext) -> SourceFragment:
87
+ """Return the expression that reconstructs this value, with its imports."""
48
88
 
49
89
 
50
90
  @dataclass(frozen=True)
@@ -65,10 +105,10 @@ class Assignment:
65
105
 
66
106
  @dataclass(frozen=True)
67
107
  class CodeBlock:
68
- items: Tuple[Any, ...]
108
+ items: tuple[Any, ...]
69
109
 
70
110
  @classmethod
71
- def from_items(cls, items: Iterable[Any]) -> "CodeBlock":
111
+ def from_items(cls, items: Iterable[Any]) -> CodeBlock:
72
112
  return cls(tuple(items))
73
113
 
74
114
 
@@ -77,11 +117,11 @@ class SourceFormatter(ABC):
77
117
 
78
118
  priority: int = 0
79
119
 
80
- _registry: ClassVar[List["SourceFormatter"]] = []
81
- _sorted_registry: ClassVar[List["SourceFormatter"]] = []
120
+ _registry: ClassVar[list[SourceFormatter]] = []
121
+ _sorted_registry: ClassVar[list[SourceFormatter]] = []
82
122
  _registry_dirty: ClassVar[bool] = False
83
123
 
84
- def __init_subclass__(cls, **kwargs):
124
+ def __init_subclass__(cls, **kwargs: Any) -> None:
85
125
  super().__init_subclass__(**kwargs)
86
126
  if getattr(cls, "__abstract__", False):
87
127
  return
@@ -99,12 +139,12 @@ class SourceFormatter(ABC):
99
139
  raise NotImplementedError
100
140
 
101
141
  @classmethod
102
- def register(cls, formatter: "SourceFormatter") -> None:
142
+ def register(cls, formatter: SourceFormatter) -> None:
103
143
  cls._registry.append(formatter)
104
144
  cls._registry_dirty = True
105
145
 
106
146
  @classmethod
107
- def _iter_registry(cls) -> List["SourceFormatter"]:
147
+ def _iter_registry(cls) -> list[SourceFormatter]:
108
148
  if cls._registry_dirty:
109
149
  cls._sorted_registry = sorted(
110
150
  cls._registry,
@@ -115,7 +155,7 @@ class SourceFormatter(ABC):
115
155
  return cls._sorted_registry
116
156
 
117
157
  @classmethod
118
- def get_formatter(cls, value: Any) -> "SourceFormatter":
158
+ def get_formatter(cls, value: Any) -> SourceFormatter:
119
159
  for formatter in cls._iter_registry():
120
160
  if formatter.can_format(value):
121
161
  return formatter
@@ -135,21 +175,28 @@ def generate_python_source(
135
175
  obj: Any,
136
176
  header: str = "",
137
177
  clean_mode: bool = False,
138
- *,
139
- context: FormatContext | None = None,
140
178
  ) -> str:
141
- """Generate complete Python source with imports."""
179
+ """Generate complete Python source with imports.
180
+
181
+ Absolute paths that share a root or repeat are bound once at the top of the
182
+ document (see :mod:`pycodify.paths`).
183
+ """
142
184
  from .imports import resolve_imports
185
+ from .paths import PathCollector, PathFactoring
143
186
 
144
- ctx = context or FormatContext(clean_mode=clean_mode)
145
- fragment = to_source(obj, ctx)
187
+ ctx = FormatContext(clean_mode=clean_mode)
188
+ collector = PathCollector()
189
+ to_source(obj, replace(ctx, paths=collector))
190
+ factoring = PathFactoring.from_occurrences(collector.values)
191
+ body = factoring.document(obj)
192
+ ctx = replace(ctx, paths=factoring)
146
193
 
194
+ fragment = to_source(body, ctx)
147
195
  import_lines, name_mappings = resolve_imports(fragment.imports)
148
-
149
196
  ctx = replace(ctx, name_mappings=MappingProxyType(name_mappings))
150
- fragment = to_source(obj, ctx)
197
+ fragment = to_source(body, ctx)
151
198
 
152
- code_lines: List[str] = []
199
+ code_lines: list[str] = []
153
200
  if header:
154
201
  code_lines.extend(header.splitlines())
155
202
  code_lines.append("")
@@ -0,0 +1,65 @@
1
+ """A dataclass saved as one Python module of assignments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import MISSING, Field, fields
7
+ from typing import Any, TypeVar
8
+
9
+ from .core import Assignment, BlankLine, CodeBlock, generate_python_source
10
+
11
+ DocumentT = TypeVar("DocumentT", bound="SourceDocument")
12
+
13
+
14
+ class SourceDocument:
15
+ """A dataclass whose constructor fields are a module's public assignments.
16
+
17
+ Each ``init=True`` field is written as one module-level assignment named
18
+ after the field, in declaration order, and read back by that name. The
19
+ field declaration is the only place the name is spelled. A field with a
20
+ default may be absent from a loaded module; a field without one must be
21
+ assigned. ``init=False`` fields are not part of the saved module.
22
+ """
23
+
24
+ __slots__ = ()
25
+
26
+ @classmethod
27
+ def assignment_fields(cls) -> tuple[Field[Any], ...]:
28
+ """Return the fields saved as assignments, in declaration order."""
29
+ return tuple(field for field in fields(cls) if field.init) # type: ignore[arg-type]
30
+
31
+ @classmethod
32
+ def assignment_names(cls) -> tuple[str, ...]:
33
+ """Return the names this document assigns at module level."""
34
+ return tuple(field.name for field in cls.assignment_fields())
35
+
36
+ @classmethod
37
+ def from_namespace(cls: type[DocumentT], namespace: Mapping[str, object]) -> DocumentT:
38
+ """Build the document from the names an executed module defines."""
39
+ values: dict[str, object] = {}
40
+ for field in cls.assignment_fields():
41
+ if field.name in namespace:
42
+ values[field.name] = namespace[field.name]
43
+ elif field.default is MISSING and field.default_factory is MISSING:
44
+ raise ValueError(f"{cls.__name__} source must define {field.name!r}.")
45
+ return cls(**values)
46
+
47
+ @classmethod
48
+ def from_source(cls: type[DocumentT], source: str) -> DocumentT:
49
+ """Execute Python source and build the document from its assignments."""
50
+ namespace: dict[str, object] = {}
51
+ exec(compile(source, f"<{cls.__name__}>", "exec"), namespace) # noqa: S102
52
+ return cls.from_namespace(namespace)
53
+
54
+ def body(self) -> CodeBlock:
55
+ """Return one assignment per saved field, separated by blank lines."""
56
+ items: list[object] = []
57
+ for name in self.assignment_names():
58
+ if items:
59
+ items.append(BlankLine())
60
+ items.append(Assignment(name, getattr(self, name)))
61
+ return CodeBlock.from_items(items)
62
+
63
+ def to_source(self, *, header: str = "", clean_mode: bool = False) -> str:
64
+ """Render the document as a complete Python module."""
65
+ return generate_python_source(self.body(), header=header, clean_mode=clean_mode)