pycodify 0.1.0__py3-none-any.whl
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/__init__.py +30 -0
- pycodify/core.py +145 -0
- pycodify/formatters.py +348 -0
- pycodify/imports.py +36 -0
- pycodify-0.1.0.dist-info/METADATA +89 -0
- pycodify-0.1.0.dist-info/RECORD +8 -0
- pycodify-0.1.0.dist-info/WHEEL +4 -0
- pycodify-0.1.0.dist-info/licenses/LICENSE +21 -0
pycodify/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
Assignment,
|
|
3
|
+
BlankLine,
|
|
4
|
+
CodeBlock,
|
|
5
|
+
Comment,
|
|
6
|
+
FormatContext,
|
|
7
|
+
SourceFormatter,
|
|
8
|
+
SourceFragment,
|
|
9
|
+
generate_python_source,
|
|
10
|
+
to_source,
|
|
11
|
+
)
|
|
12
|
+
from .imports import resolve_imports
|
|
13
|
+
|
|
14
|
+
# Register default formatters
|
|
15
|
+
from . import formatters as _formatters # noqa: F401
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.1"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Assignment",
|
|
21
|
+
"BlankLine",
|
|
22
|
+
"CodeBlock",
|
|
23
|
+
"Comment",
|
|
24
|
+
"FormatContext",
|
|
25
|
+
"SourceFormatter",
|
|
26
|
+
"SourceFragment",
|
|
27
|
+
"generate_python_source",
|
|
28
|
+
"resolve_imports",
|
|
29
|
+
"to_source",
|
|
30
|
+
]
|
pycodify/core.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from typing import Any, ClassVar, Dict, FrozenSet, Iterable, List, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SourceFragment:
|
|
10
|
+
"""Result of serializing a value to Python source."""
|
|
11
|
+
|
|
12
|
+
code: str
|
|
13
|
+
imports: FrozenSet[Tuple[str, str]] = frozenset()
|
|
14
|
+
|
|
15
|
+
def __add__(self, other: "SourceFragment") -> "SourceFragment":
|
|
16
|
+
return SourceFragment(
|
|
17
|
+
code=self.code + other.code,
|
|
18
|
+
imports=self.imports | other.imports,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class FormatContext:
|
|
24
|
+
"""Context passed through serialization."""
|
|
25
|
+
|
|
26
|
+
indent: int = 0
|
|
27
|
+
clean_mode: bool = False
|
|
28
|
+
name_mappings: Dict[Tuple[str, str], str] = field(default_factory=dict)
|
|
29
|
+
|
|
30
|
+
def indented(self) -> "FormatContext":
|
|
31
|
+
return replace(self, indent=self.indent + 1)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def indent_str(self) -> str:
|
|
35
|
+
return " " * self.indent
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Comment:
|
|
40
|
+
text: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class BlankLine:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Assignment:
|
|
50
|
+
name: str
|
|
51
|
+
value: Any
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class CodeBlock:
|
|
56
|
+
items: Tuple[Any, ...]
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_items(cls, items: Iterable[Any]) -> "CodeBlock":
|
|
60
|
+
return cls(tuple(items))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SourceFormatter(ABC):
|
|
64
|
+
"""Formats a value to Python source code."""
|
|
65
|
+
|
|
66
|
+
priority: int = 0
|
|
67
|
+
|
|
68
|
+
_registry: ClassVar[List["SourceFormatter"]] = []
|
|
69
|
+
_sorted_registry: ClassVar[List["SourceFormatter"]] = []
|
|
70
|
+
_registry_dirty: ClassVar[bool] = False
|
|
71
|
+
|
|
72
|
+
def __init_subclass__(cls, **kwargs):
|
|
73
|
+
super().__init_subclass__(**kwargs)
|
|
74
|
+
if getattr(cls, "__abstract__", False):
|
|
75
|
+
return
|
|
76
|
+
SourceFormatter._registry.append(cls())
|
|
77
|
+
SourceFormatter._registry_dirty = True
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def can_format(self, value: Any) -> bool:
|
|
81
|
+
"""Return True if this formatter handles this value type."""
|
|
82
|
+
raise NotImplementedError
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def format(self, value: Any, context: FormatContext) -> SourceFragment:
|
|
86
|
+
"""Format value to Python source, returning code and required imports."""
|
|
87
|
+
raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def register(cls, formatter: "SourceFormatter") -> None:
|
|
91
|
+
cls._registry.append(formatter)
|
|
92
|
+
cls._registry_dirty = True
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def _iter_registry(cls) -> List["SourceFormatter"]:
|
|
96
|
+
if cls._registry_dirty:
|
|
97
|
+
cls._sorted_registry = sorted(
|
|
98
|
+
cls._registry,
|
|
99
|
+
key=lambda fmt: fmt.priority,
|
|
100
|
+
reverse=True,
|
|
101
|
+
)
|
|
102
|
+
cls._registry_dirty = False
|
|
103
|
+
return cls._sorted_registry
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def get_formatter(cls, value: Any) -> "SourceFormatter":
|
|
107
|
+
for formatter in cls._iter_registry():
|
|
108
|
+
if formatter.can_format(value):
|
|
109
|
+
return formatter
|
|
110
|
+
raise TypeError(f"No formatter for {type(value)}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def to_source(value: Any, ctx: FormatContext | None = None) -> SourceFragment:
|
|
114
|
+
"""Convert any value to Python source code."""
|
|
115
|
+
if isinstance(value, SourceFragment):
|
|
116
|
+
return value
|
|
117
|
+
ctx = ctx or FormatContext()
|
|
118
|
+
formatter = SourceFormatter.get_formatter(value)
|
|
119
|
+
return formatter.format(value, ctx)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def generate_python_source(obj: Any, header: str = "", clean_mode: bool = False) -> str:
|
|
123
|
+
"""Generate complete Python source with imports."""
|
|
124
|
+
from .imports import resolve_imports
|
|
125
|
+
|
|
126
|
+
ctx = FormatContext(clean_mode=clean_mode)
|
|
127
|
+
fragment = to_source(obj, ctx)
|
|
128
|
+
|
|
129
|
+
import_lines, name_mappings = resolve_imports(fragment.imports)
|
|
130
|
+
|
|
131
|
+
ctx = FormatContext(clean_mode=clean_mode, name_mappings=name_mappings)
|
|
132
|
+
fragment = to_source(obj, ctx)
|
|
133
|
+
|
|
134
|
+
code_lines: List[str] = []
|
|
135
|
+
if header:
|
|
136
|
+
code_lines.extend(header.splitlines())
|
|
137
|
+
code_lines.append("")
|
|
138
|
+
if import_lines:
|
|
139
|
+
code_lines.extend(import_lines)
|
|
140
|
+
code_lines.append("")
|
|
141
|
+
|
|
142
|
+
if fragment.code:
|
|
143
|
+
code_lines.append(fragment.code)
|
|
144
|
+
|
|
145
|
+
return "\n".join(code_lines)
|
pycodify/formatters.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import typing
|
|
5
|
+
from dataclasses import is_dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Tuple
|
|
9
|
+
|
|
10
|
+
from .core import (
|
|
11
|
+
Assignment,
|
|
12
|
+
BlankLine,
|
|
13
|
+
CodeBlock,
|
|
14
|
+
Comment,
|
|
15
|
+
FormatContext,
|
|
16
|
+
SourceFormatter,
|
|
17
|
+
SourceFragment,
|
|
18
|
+
to_source,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CommentFormatter(SourceFormatter):
|
|
23
|
+
priority = 100
|
|
24
|
+
|
|
25
|
+
def can_format(self, value: Any) -> bool:
|
|
26
|
+
return isinstance(value, Comment)
|
|
27
|
+
|
|
28
|
+
def format(self, value: Comment, context: FormatContext) -> SourceFragment:
|
|
29
|
+
text = value.text.strip()
|
|
30
|
+
if not text:
|
|
31
|
+
code = "#"
|
|
32
|
+
elif text.lstrip().startswith("#"):
|
|
33
|
+
code = text
|
|
34
|
+
else:
|
|
35
|
+
code = f"# {text}"
|
|
36
|
+
return SourceFragment(f"{context.indent_str}{code}", frozenset())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BlankLineFormatter(SourceFormatter):
|
|
40
|
+
priority = 100
|
|
41
|
+
|
|
42
|
+
def can_format(self, value: Any) -> bool:
|
|
43
|
+
return isinstance(value, BlankLine)
|
|
44
|
+
|
|
45
|
+
def format(self, value: BlankLine, context: FormatContext) -> SourceFragment:
|
|
46
|
+
return SourceFragment("", frozenset())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AssignmentFormatter(SourceFormatter):
|
|
50
|
+
priority = 90
|
|
51
|
+
|
|
52
|
+
def can_format(self, value: Any) -> bool:
|
|
53
|
+
return isinstance(value, Assignment)
|
|
54
|
+
|
|
55
|
+
def format(self, value: Assignment, context: FormatContext) -> SourceFragment:
|
|
56
|
+
fragment = to_source(value.value, context)
|
|
57
|
+
code = f"{context.indent_str}{value.name} = {fragment.code}"
|
|
58
|
+
return SourceFragment(code, fragment.imports)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CodeBlockFormatter(SourceFormatter):
|
|
62
|
+
priority = 80
|
|
63
|
+
|
|
64
|
+
def can_format(self, value: Any) -> bool:
|
|
65
|
+
return isinstance(value, CodeBlock)
|
|
66
|
+
|
|
67
|
+
def format(self, value: CodeBlock, context: FormatContext) -> SourceFragment:
|
|
68
|
+
codes = []
|
|
69
|
+
imports = set()
|
|
70
|
+
for item in value.items:
|
|
71
|
+
fragment = to_source(item, context)
|
|
72
|
+
imports |= fragment.imports
|
|
73
|
+
codes.append(fragment.code)
|
|
74
|
+
return SourceFragment("\n".join(codes), frozenset(imports))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class EnumFormatter(SourceFormatter):
|
|
78
|
+
priority = 70
|
|
79
|
+
|
|
80
|
+
def can_format(self, value: Any) -> bool:
|
|
81
|
+
return isinstance(value, Enum)
|
|
82
|
+
|
|
83
|
+
def format(self, value: Enum, context: FormatContext) -> SourceFragment:
|
|
84
|
+
cls = value.__class__
|
|
85
|
+
import_pair = (cls.__module__, cls.__name__)
|
|
86
|
+
name = context.name_mappings.get(import_pair, cls.__name__)
|
|
87
|
+
return SourceFragment(f"{name}.{value.name}", frozenset([import_pair]))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PathFormatter(SourceFormatter):
|
|
91
|
+
priority = 70
|
|
92
|
+
|
|
93
|
+
def can_format(self, value: Any) -> bool:
|
|
94
|
+
return isinstance(value, Path)
|
|
95
|
+
|
|
96
|
+
def format(self, value: Path, context: FormatContext) -> SourceFragment:
|
|
97
|
+
import_pair = ("pathlib", "Path")
|
|
98
|
+
name = context.name_mappings.get(import_pair, "Path")
|
|
99
|
+
return SourceFragment(f"{name}({repr(str(value))})", frozenset([import_pair]))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class StrFormatter(SourceFormatter):
|
|
103
|
+
priority = 60
|
|
104
|
+
|
|
105
|
+
def can_format(self, value: Any) -> bool:
|
|
106
|
+
return isinstance(value, str)
|
|
107
|
+
|
|
108
|
+
def format(self, value: str, context: FormatContext) -> SourceFragment:
|
|
109
|
+
return SourceFragment(repr(value), frozenset())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class LiteralFormatter(SourceFormatter):
|
|
113
|
+
priority = 50
|
|
114
|
+
|
|
115
|
+
def can_format(self, value: Any) -> bool:
|
|
116
|
+
return value is None or isinstance(value, (bool, int, float))
|
|
117
|
+
|
|
118
|
+
def format(self, value: Any, context: FormatContext) -> SourceFragment:
|
|
119
|
+
return SourceFragment(repr(value), frozenset())
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class CallableFormatter(SourceFormatter):
|
|
123
|
+
priority = 40
|
|
124
|
+
|
|
125
|
+
def can_format(self, value: Any) -> bool:
|
|
126
|
+
return callable(value) and not isinstance(value, type)
|
|
127
|
+
|
|
128
|
+
def format(self, value: Any, context: FormatContext) -> SourceFragment:
|
|
129
|
+
import_pair = (value.__module__, value.__name__)
|
|
130
|
+
name = context.name_mappings.get(import_pair, value.__name__)
|
|
131
|
+
return SourceFragment(name, frozenset([import_pair]))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ListFormatter(SourceFormatter):
|
|
135
|
+
priority = 30
|
|
136
|
+
|
|
137
|
+
def can_format(self, value: Any) -> bool:
|
|
138
|
+
return isinstance(value, list)
|
|
139
|
+
|
|
140
|
+
def format(self, value: list, context: FormatContext) -> SourceFragment:
|
|
141
|
+
if not value:
|
|
142
|
+
return SourceFragment("[]", frozenset())
|
|
143
|
+
|
|
144
|
+
item_ctx = context.indented()
|
|
145
|
+
item_frags = [to_source(item, item_ctx) for item in value]
|
|
146
|
+
imports = frozenset().union(*(frag.imports for frag in item_frags))
|
|
147
|
+
inner = f",\n{item_ctx.indent_str}".join(frag.code for frag in item_frags)
|
|
148
|
+
code = f"[\n{item_ctx.indent_str}{inner}\n{context.indent_str}]"
|
|
149
|
+
return SourceFragment(code, imports)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class TupleFormatter(SourceFormatter):
|
|
153
|
+
priority = 30
|
|
154
|
+
|
|
155
|
+
def can_format(self, value: Any) -> bool:
|
|
156
|
+
return isinstance(value, tuple)
|
|
157
|
+
|
|
158
|
+
def format(self, value: tuple, context: FormatContext) -> SourceFragment:
|
|
159
|
+
if not value:
|
|
160
|
+
return SourceFragment("()", frozenset())
|
|
161
|
+
|
|
162
|
+
item_ctx = context.indented()
|
|
163
|
+
item_frags = [to_source(item, item_ctx) for item in value]
|
|
164
|
+
imports = frozenset().union(*(frag.imports for frag in item_frags))
|
|
165
|
+
inner = f",\n{item_ctx.indent_str}".join(frag.code for frag in item_frags)
|
|
166
|
+
if len(value) == 1:
|
|
167
|
+
inner = f"{inner},"
|
|
168
|
+
code = f"(\n{item_ctx.indent_str}{inner}\n{context.indent_str})"
|
|
169
|
+
return SourceFragment(code, imports)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DictFormatter(SourceFormatter):
|
|
173
|
+
priority = 30
|
|
174
|
+
|
|
175
|
+
def can_format(self, value: Any) -> bool:
|
|
176
|
+
return isinstance(value, dict)
|
|
177
|
+
|
|
178
|
+
def format(self, value: dict, context: FormatContext) -> SourceFragment:
|
|
179
|
+
if not value:
|
|
180
|
+
return SourceFragment("{}", frozenset())
|
|
181
|
+
|
|
182
|
+
item_ctx = context.indented()
|
|
183
|
+
entries = []
|
|
184
|
+
imports = set()
|
|
185
|
+
for key, val in value.items():
|
|
186
|
+
key_frag = to_source(key, item_ctx)
|
|
187
|
+
val_frag = to_source(val, item_ctx)
|
|
188
|
+
imports |= key_frag.imports
|
|
189
|
+
imports |= val_frag.imports
|
|
190
|
+
entries.append(f"{key_frag.code}: {val_frag.code}")
|
|
191
|
+
|
|
192
|
+
inner = f",\n{item_ctx.indent_str}".join(entries)
|
|
193
|
+
code = f"{{\n{item_ctx.indent_str}{inner}\n{context.indent_str}}}"
|
|
194
|
+
return SourceFragment(code, frozenset(imports))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class SetFormatter(SourceFormatter):
|
|
198
|
+
priority = 30
|
|
199
|
+
|
|
200
|
+
def can_format(self, value: Any) -> bool:
|
|
201
|
+
return isinstance(value, set)
|
|
202
|
+
|
|
203
|
+
def format(self, value: set, context: FormatContext) -> SourceFragment:
|
|
204
|
+
if not value:
|
|
205
|
+
return SourceFragment("set()", frozenset())
|
|
206
|
+
|
|
207
|
+
item_ctx = context.indented()
|
|
208
|
+
item_frags = [to_source(item, item_ctx) for item in value]
|
|
209
|
+
imports = frozenset().union(*(frag.imports for frag in item_frags))
|
|
210
|
+
inner = f",\n{item_ctx.indent_str}".join(frag.code for frag in item_frags)
|
|
211
|
+
code = f"{{\n{item_ctx.indent_str}{inner}\n{context.indent_str}}}"
|
|
212
|
+
return SourceFragment(code, imports)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class DataclassFormatter(SourceFormatter):
|
|
216
|
+
priority = 35
|
|
217
|
+
|
|
218
|
+
def can_format(self, value: Any) -> bool:
|
|
219
|
+
return is_dataclass(value) and not isinstance(value, type)
|
|
220
|
+
|
|
221
|
+
def format(self, value: Any, context: FormatContext) -> SourceFragment:
|
|
222
|
+
code, imports, _ = _format_dataclass_expression(value, context)
|
|
223
|
+
return SourceFragment(code, frozenset(imports))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _resolve_dataclass_class_from_type(field_type: Any) -> Any | None:
|
|
227
|
+
if field_type is None:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
origin = typing.get_origin(field_type)
|
|
231
|
+
if origin is typing.Union:
|
|
232
|
+
for arg in typing.get_args(field_type):
|
|
233
|
+
if arg is type(None):
|
|
234
|
+
continue
|
|
235
|
+
resolved = _resolve_dataclass_class_from_type(arg)
|
|
236
|
+
if resolved:
|
|
237
|
+
return resolved
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
if isinstance(field_type, type) and dataclasses.is_dataclass(field_type):
|
|
241
|
+
return field_type
|
|
242
|
+
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _create_placeholder_dataclass_instance(dataclass_cls: type) -> Any:
|
|
247
|
+
instance = object.__new__(dataclass_cls)
|
|
248
|
+
for dc_field in dataclasses.fields(dataclass_cls):
|
|
249
|
+
object.__setattr__(instance, dc_field.name, None)
|
|
250
|
+
|
|
251
|
+
if hasattr(dataclass_cls, "_is_lazy_dataclass"):
|
|
252
|
+
object.__setattr__(instance, "_is_lazy_dataclass", True)
|
|
253
|
+
|
|
254
|
+
return instance
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _create_default_instance(instance: Any) -> Any:
|
|
258
|
+
if hasattr(instance, "_resolve_field_value"):
|
|
259
|
+
default_instance = object.__new__(instance.__class__)
|
|
260
|
+
for dc_field in dataclasses.fields(instance):
|
|
261
|
+
object.__setattr__(default_instance, dc_field.name, None)
|
|
262
|
+
if hasattr(instance.__class__, "_is_lazy_dataclass"):
|
|
263
|
+
object.__setattr__(default_instance, "_is_lazy_dataclass", True)
|
|
264
|
+
return default_instance
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
return instance.__class__()
|
|
268
|
+
except Exception:
|
|
269
|
+
default_instance = object.__new__(instance.__class__)
|
|
270
|
+
for dc_field in dataclasses.fields(instance):
|
|
271
|
+
if dc_field.default is not dataclasses.MISSING:
|
|
272
|
+
value = dc_field.default
|
|
273
|
+
elif dc_field.default_factory is not dataclasses.MISSING: # type: ignore
|
|
274
|
+
value = dc_field.default_factory() # type: ignore
|
|
275
|
+
else:
|
|
276
|
+
value = None
|
|
277
|
+
object.__setattr__(default_instance, dc_field.name, value)
|
|
278
|
+
return default_instance
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _format_dataclass_expression(
|
|
282
|
+
instance: Any,
|
|
283
|
+
context: FormatContext,
|
|
284
|
+
) -> Tuple[str, set[Tuple[str, str]], bool]:
|
|
285
|
+
cls = instance.__class__
|
|
286
|
+
import_pair = (cls.__module__, cls.__name__)
|
|
287
|
+
class_name = context.name_mappings.get(import_pair, cls.__name__)
|
|
288
|
+
imports: set[Tuple[str, str]] = {import_pair}
|
|
289
|
+
|
|
290
|
+
default_instance = _create_default_instance(instance)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
type_hints = typing.get_type_hints(instance.__class__)
|
|
294
|
+
except Exception:
|
|
295
|
+
type_hints = {}
|
|
296
|
+
|
|
297
|
+
lines: list[str] = []
|
|
298
|
+
field_ctx = context.indented()
|
|
299
|
+
|
|
300
|
+
for dc_field in dataclasses.fields(instance):
|
|
301
|
+
field_name = dc_field.name
|
|
302
|
+
|
|
303
|
+
if hasattr(instance, "_resolve_field_value"):
|
|
304
|
+
current_value = object.__getattribute__(instance, field_name)
|
|
305
|
+
default_value = object.__getattribute__(default_instance, field_name)
|
|
306
|
+
else:
|
|
307
|
+
current_value = getattr(instance, field_name)
|
|
308
|
+
default_value = getattr(default_instance, field_name)
|
|
309
|
+
|
|
310
|
+
if (not context.clean_mode) and current_value is None:
|
|
311
|
+
field_type = type_hints.get(field_name, dc_field.type)
|
|
312
|
+
dataclass_cls = _resolve_dataclass_class_from_type(field_type)
|
|
313
|
+
if dataclass_cls is not None:
|
|
314
|
+
current_value = _create_placeholder_dataclass_instance(dataclass_cls)
|
|
315
|
+
|
|
316
|
+
if context.clean_mode and current_value == default_value:
|
|
317
|
+
continue
|
|
318
|
+
|
|
319
|
+
if dataclasses.is_dataclass(current_value) and not isinstance(current_value, type):
|
|
320
|
+
nested_code, nested_imports, nested_has_fields = _format_dataclass_expression(
|
|
321
|
+
current_value,
|
|
322
|
+
field_ctx,
|
|
323
|
+
)
|
|
324
|
+
if context.clean_mode and not nested_has_fields:
|
|
325
|
+
continue
|
|
326
|
+
imports |= nested_imports
|
|
327
|
+
lines.append(f"{field_name}={nested_code}")
|
|
328
|
+
else:
|
|
329
|
+
frag = to_source(current_value, field_ctx)
|
|
330
|
+
imports |= frag.imports
|
|
331
|
+
lines.append(f"{field_name}={frag.code}")
|
|
332
|
+
|
|
333
|
+
if not lines:
|
|
334
|
+
return f"{class_name}()", imports, False
|
|
335
|
+
|
|
336
|
+
inner = f",\n{field_ctx.indent_str}".join(lines)
|
|
337
|
+
code = f"{class_name}(\n{field_ctx.indent_str}{inner}\n{context.indent_str})"
|
|
338
|
+
return code, imports, True
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class FallbackFormatter(SourceFormatter):
|
|
342
|
+
priority = -100
|
|
343
|
+
|
|
344
|
+
def can_format(self, value: Any) -> bool:
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
def format(self, value: Any, context: FormatContext) -> SourceFragment:
|
|
348
|
+
return SourceFragment(repr(value), frozenset())
|
pycodify/imports.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pycodify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python source code as a serialization format with automatic import resolution
|
|
5
|
+
Project-URL: Homepage, https://github.com/OpenHCSDev/pycodify
|
|
6
|
+
Project-URL: Documentation, https://pycodify.readthedocs.io
|
|
7
|
+
Project-URL: Repository, https://github.com/OpenHCSDev/pycodify
|
|
8
|
+
Project-URL: Issues, https://github.com/OpenHCSDev/pycodify/issues
|
|
9
|
+
Author-email: Tristan Simas <tristan.simas@mail.mcgill.ca>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: code-generation,configuration,python-source,reproducibility,serialization
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: black>=23.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
30
|
+
Provides-Extra: docs
|
|
31
|
+
Requires-Dist: sphinx-autodoc-typehints>=1.25; extra == 'docs'
|
|
32
|
+
Requires-Dist: sphinx-rtd-theme>=2.0; extra == 'docs'
|
|
33
|
+
Requires-Dist: sphinx>=7.0; extra == 'docs'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# pycodify
|
|
37
|
+
|
|
38
|
+
Python source code as a serialization format with automatic import resolution.
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from pycodify import Assignment, generate_python_source
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Config:
|
|
48
|
+
name: str = "default"
|
|
49
|
+
value: int = 42
|
|
50
|
+
|
|
51
|
+
config = Config(name="production", value=100)
|
|
52
|
+
code = generate_python_source(Assignment("config", config), clean_mode=True)
|
|
53
|
+
print(code)
|
|
54
|
+
# Output:
|
|
55
|
+
# from __main__ import Config
|
|
56
|
+
# config = Config(name='production', value=100)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Why Python Source?
|
|
60
|
+
|
|
61
|
+
| Format | Diffable | Inspectable | Editable | Type-preserving | Cross-version |
|
|
62
|
+
|--------|:--------:|:-----------:|:--------:|:---------------:|:-------------:|
|
|
63
|
+
| pickle | ✗ | ✗ | ✗ | ✓ | ✗ |
|
|
64
|
+
| JSON/YAML | ✓ | ✓ | ✓ | ✗ | ✓ |
|
|
65
|
+
| Python source | ✓ | ✓ | ✓ | ✓ | ✓ |
|
|
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.
|
|
68
|
+
|
|
69
|
+
## Features
|
|
70
|
+
|
|
71
|
+
- **Complete Executable Source**: Generates imports + code, not just expressions
|
|
72
|
+
- **Type-Preserving**: Enums, Paths, callables serialize as themselves
|
|
73
|
+
- **Collision Handling**: Automatic aliasing for name collisions across modules
|
|
74
|
+
- **Clean Mode**: Omit fields matching defaults for concise output
|
|
75
|
+
- **Extensible**: Register custom formatters for domain-specific types
|
|
76
|
+
|
|
77
|
+
## Documentation
|
|
78
|
+
|
|
79
|
+
Full documentation available at [pycodify.readthedocs.io](https://pycodify.readthedocs.io)
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install pycodify
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pycodify/__init__.py,sha256=gHiMGKUAfA2OXU1gBtMPqIQR6rUoZRiH9hLhHjruhf0,547
|
|
2
|
+
pycodify/core.py,sha256=o4Fhpxx8QOFrXBSxhNLB6KweJbGeM1pR2oDxpYbCLuo,4065
|
|
3
|
+
pycodify/formatters.py,sha256=Zm50i71UXX1lfiGqRz97-zXRrYBT8NcNA9cqAlrlW2k,11774
|
|
4
|
+
pycodify/imports.py,sha256=QIClblhNdr8pTkBLjsVN0iFxVv39PYEwz3OkuggkwwI,1226
|
|
5
|
+
pycodify-0.1.0.dist-info/METADATA,sha256=Y0iGtSeVCmkWb5a-U-zcby97W7WLAmUeWrVGPXxcM4s,3182
|
|
6
|
+
pycodify-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
7
|
+
pycodify-0.1.0.dist-info/licenses/LICENSE,sha256=QaLlb9Yy_FmAXOzvvTABEDPQ5n_M3ck1KaGmz_A6p2Y,1064
|
|
8
|
+
pycodify-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OpenHCS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|