pycodify 0.1.0__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.
- {pycodify-0.1.0 → pycodify-0.1.3}/PKG-INFO +15 -4
- {pycodify-0.1.0 → pycodify-0.1.3}/README.md +14 -3
- pycodify-0.1.3/docs/api.rst +32 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/docs/architecture.rst +53 -11
- {pycodify-0.1.0 → pycodify-0.1.3}/docs/conf.py +4 -5
- pycodify-0.1.3/docs/index.rst +38 -0
- pycodify-0.1.3/docs/usage.rst +226 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/pyproject.toml +1 -1
- {pycodify-0.1.0 → pycodify-0.1.3}/scripts/update_version.py +11 -1
- {pycodify-0.1.0 → pycodify-0.1.3}/src/pycodify/__init__.py +1 -1
- {pycodify-0.1.0 → pycodify-0.1.3}/src/pycodify/core.py +24 -6
- {pycodify-0.1.0 → pycodify-0.1.3}/src/pycodify/formatters.py +7 -0
- pycodify-0.1.3/src/pycodify/imports.py +72 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/tests/test_core.py +37 -2
- pycodify-0.1.3/tests/test_imports.py +53 -0
- pycodify-0.1.0/docs/api.rst +0 -106
- pycodify-0.1.0/docs/index.rst +0 -72
- pycodify-0.1.0/docs/usage.rst +0 -129
- pycodify-0.1.0/src/pycodify/imports.py +0 -36
- {pycodify-0.1.0 → pycodify-0.1.3}/.github/workflows/ci.yml +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/.github/workflows/publish.yml +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/.gitignore +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/CONTRIBUTING.md +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/LICENSE +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/docs/Makefile +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/paper.bib +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/paper.md +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/scripts/release.py +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/scripts/update_and_release.py +0 -0
- {pycodify-0.1.0 → pycodify-0.1.3}/scripts/verify_release_ready.py +0 -0
- {pycodify-0.1.0 → 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.
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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,
|
|
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
|
|
13
|
-
release = "0.1.
|
|
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.
|
|
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"}
|
|
@@ -74,8 +74,18 @@ def update_version(new_version=None):
|
|
|
74
74
|
)
|
|
75
75
|
init_file.write_text(new_content)
|
|
76
76
|
|
|
77
|
+
# Update version in pyproject.toml
|
|
78
|
+
pyproject_file = Path("pyproject.toml")
|
|
79
|
+
content = pyproject_file.read_text()
|
|
80
|
+
new_content = re.sub(
|
|
81
|
+
r'(version\s*=\s*[\'"])[^\'"]+([\'"])',
|
|
82
|
+
rf'\g<1>{new_version}\2',
|
|
83
|
+
content
|
|
84
|
+
)
|
|
85
|
+
pyproject_file.write_text(new_content)
|
|
86
|
+
|
|
77
87
|
# Commit and push changes
|
|
78
|
-
subprocess.run(['git', 'add', 'src/pycodify/__init__.py'], check=True)
|
|
88
|
+
subprocess.run(['git', 'add', 'src/pycodify/__init__.py', 'pyproject.toml'], check=True)
|
|
79
89
|
subprocess.run(['git', 'commit', '-m', f'bump version to {new_version}'], check=True)
|
|
80
90
|
subprocess.run(['git', 'push', 'origin', 'main'], check=True)
|
|
81
91
|
|
|
@@ -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
|
|
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:
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
+
}
|
pycodify-0.1.0/docs/api.rst
DELETED
|
@@ -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
|
-
|
pycodify-0.1.0/docs/index.rst
DELETED
|
@@ -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
|
-
|
pycodify-0.1.0/docs/usage.rst
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|