Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ API orientation
``Assignment`` and ``CodeBlock``
Structured top-level source items.

``generate_python_source(obj, header="", clean_mode=False, *, context=None)``
Generate complete executable source and required imports. A caller-supplied
``FormatContext`` is retained across both render passes and owns clean mode.
``generate_python_source(obj, header="", clean_mode=False)``
Generate complete executable source and required imports, with shared and
repeated absolute paths bound once at the top.

``to_source(value, context=None)``
Format one value into a ``SourceFragment``.
Expand All @@ -19,8 +19,17 @@ API orientation
Resolve import requirements and aliases for colliding names.

``FormatContext`` and ``SourceFragment``
Immutable typed formatting state and output records. ``FormatContext`` may
carry exact-type ``extensions`` and exposes them through ``extension(type)``.
Immutable formatting state and output records.

``imported_name(module, qualname, context)``
Spell an importable object by its qualified name, honouring collision aliases.

``PythonSourceLiteral``
Base for values that spell their own source through ``source_fragment``.

``PathRendering``, ``PathCollector``, ``PathFactoring``, ``PathConstructor``
How a render pass spells paths: plainly, while recording, or through the
document's path bindings.

The canonical import surface is ``pycodify.__all__``.

Expand Down
81 changes: 37 additions & 44 deletions docs/architecture.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ Two-Pass Algorithm

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.

pycodify solves this with two passes:
pycodify solves this with two passes, preceded by a path-discovery pass
(see *Path factoring* below):

1. **Collection pass**: Traverse the object, emit code fragments, and collect
``(module, name)`` import pairs.
Expand All @@ -31,12 +32,26 @@ Each type maps to a ``SourceFormatter`` that emits a ``SourceFragment(code, impo
return isinstance(value, Enum)

def format(self, value: Enum, context: FormatContext) -> SourceFragment:
cls = value.__class__
import_pair = (cls.__module__, cls.__name__)
name = context.name_mappings.get(import_pair, cls.__name__)
return SourceFragment(f"{name}.{value.name}", frozenset([import_pair]))
enum_type = imported_name(type(value).__module__, type(value).__qualname__, context)
return SourceFragment(f"{enum_type.code}.{value.name}", enum_type.imports)

Priority-based dispatch selects the most specific formatter. Domain extensions add formatters without modifying core code.
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.

Qualified names
---------------

``imported_name(module, qualname, context)`` is the one rule for spelling an
importable object. It imports the root of ``__qualname__`` and spells the rest as
attribute access, so ``Outer.Mode.FAST`` imports ``Outer``. Enum members,
classes, functions, class-bound methods, named tuples and dataclass constructors
all use it. Objects defined inside a function raise ``TypeError``.

Values that own their spelling
------------------------------

A value whose source is not its constructor subclasses ``PythonSourceLiteral``
and implements ``source_fragment(context)``, typically building on
``imported_name`` so collision aliases apply.

Core Components
---------------
Expand All @@ -49,7 +64,7 @@ Core Components

**FormatContext**
Carries indentation depth, ``clean_mode``, the ``(module, name)`` to
local-name mapping, and caller-owned typed extensions. ``indented()`` returns
local-name mapping, and the pass's ``PathRendering``. ``indented()`` returns
a copied context and does not mutate its caller.

**resolve_imports**
Expand All @@ -60,27 +75,17 @@ Core Components
Orchestrates collection, resolution, and regeneration to produce a complete
source string.

Immutable typed context extensions
----------------------------------

``FormatContext`` is frozen. Its default mappings are read-only mapping proxies,
and context evolution uses dataclass replacement rather than mutation. A host
may supply an ``extensions`` mapping keyed by the exact nominal type of each
rendering policy or state record. A formatter retrieves one value with
``context.extension(ExtensionType)``; pycodify does not search base classes,
string keys, or a fallback chain.

``generate_python_source(..., context=context)`` threads the caller's context
through collection and regeneration. Import resolution replaces only
``name_mappings`` for the second pass, so typed extensions and all other context
fields survive. When a context is supplied, its ``clean_mode`` is authoritative;
the separate ``clean_mode=`` argument is used only when pycodify constructs the
context.
Path factoring
--------------

Extensions provide formatting context, not formatter dispatch. New value
families still extend the ``SourceFormatter`` registry, while the exact extension
type allows that formatter to obtain host-owned rendering information without
adding host imports or fields to pycodify core.
``generate_python_source`` first renders the document with a ``PathCollector``
on ``FormatContext.paths`` to record every ``pathlib.Path`` value. A
``PathFactoring`` plan then binds each directory shared by two or more absolute
paths (``path_root = Path('/data/screen')``) and each repeated absolute path
(``path_1 = path_root / 'plate_A'``) once, at the top of the document, and the
two rendering passes spell those paths through the bindings. Relative paths and
strings are never factored. ``to_source`` alone uses the base ``PathRendering``,
which spells each path as ``Path('...')``.

Clean Mode
----------
Expand All @@ -107,25 +112,13 @@ not sandboxing or validation of generated programs. Hosts must restrict inputs
and custom formatters to trusted sources, review persisted code before running
it, and define their own compatibility policy for imported domain APIs.

Lazy Dataclass Integration
---------------------------

For dataclasses with ``__getattribute__`` interception (used for hierarchical config inheritance), formatters use ``object.__getattribute__`` to access raw field values without triggering lazy resolution:

.. code-block:: python

# In DataclassFormatter
if hasattr(instance, "_resolve_field_value"):
# Bypass __getattribute__ to get raw None vs concrete value
current_value = object.__getattribute__(instance, field_name)
else:
current_value = getattr(instance, field_name)

This distinguishes explicitly-set values from inherited ones during serialization.

Module Structure
----------------

- **core.py**: Main API (``Assignment``, ``generate_python_source``)
- **formatters.py**: Built-in formatters (dataclass, enum, primitive types)
- **formatters.py**: Built-in formatters (dataclass, enum, class, callable, primitive types)
- **paths.py**: Document-level path factoring

Lazy configuration dataclasses (ObjectState) are formatted by
``objectstate.codegen``, which registers its own formatter.
- **imports.py**: Import resolution and collision handling
57 changes: 16 additions & 41 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,62 +117,37 @@ level. ``name_mappings`` maps ``(module, imported_name)`` pairs to the local
names chosen by ``resolve_imports``; callers normally let
``generate_python_source`` populate it during the second pass.

Typed rendering extensions
--------------------------
Values that own their source
----------------------------

A host can attach immutable rendering context without adding host-specific state
to pycodify. Keys are exact nominal types, and the same context reaches both
render passes:
A value whose source is not its dataclass constructor implements
``PythonSourceLiteral``:

.. code-block:: python

from dataclasses import dataclass
from types import MappingProxyType

from pycodify import (
Assignment,
FormatContext,
SourceFormatter,
PythonSourceLiteral,
SourceFragment,
generate_python_source,
imported_name,
)

@dataclass(frozen=True)
class RenderPolicy:
marker: str

class PolicyValue:
pass

class PolicyValueFormatter(SourceFormatter):
priority = 200

def can_format(self, value):
return isinstance(value, PolicyValue)

def format(self, value, context):
del value
policy = context.extension(RenderPolicy)
if policy is None:
raise RuntimeError("RenderPolicy is required")
return SourceFragment(repr(policy.marker))

context = FormatContext(
clean_mode=True,
extensions=MappingProxyType(
{RenderPolicy: RenderPolicy(marker="preserved")}
),
)
source = generate_python_source(
Assignment("value", PolicyValue()),
context=context,
)
assert source == "value = 'preserved'"
class RegistryLookup(PythonSourceLiteral):
key: str

def source_fragment(self, context: FormatContext) -> SourceFragment:
lookup = imported_name("myapp.registry", "get_function", context)
return SourceFragment(f"{lookup.code}({self.key!r})", lookup.imports)

``extension(RenderPolicy)`` does not return a value stored under a base class or
string key. This exact-type lookup keeps extension ownership explicit. Treat the
extension mapping as immutable; use a new ``FormatContext`` when the host needs
different state.
source = generate_python_source(Assignment("func", RegistryLookup("gauss")))
# from myapp.registry import get_function
#
# func = get_function('gauss')

Nested Dataclasses
-------------------
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "pycodify"
version = "0.1.3"
version = "0.2.0"
description = "Python source code as a serialization format with automatic import resolution"
authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}]
license = {text = "MIT"}
Expand Down
17 changes: 13 additions & 4 deletions src/pycodify/__init__.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,39 @@
# Register default formatters
from . import formatters as _formatters # noqa: F401
from .core import (
Assignment,
BlankLine,
CodeBlock,
Comment,
FormatContext,
PathRendering,
PythonSourceLiteral,
SourceFormatter,
SourceFragment,
generate_python_source,
imported_name,
to_source,
)
from .imports import resolve_imports
from .paths import PathCollector, PathConstructor, PathFactoring

# Register default formatters
from . import formatters as _formatters # noqa: F401

__version__ = "0.1.3"
__version__ = "0.2.0"

__all__ = [
"Assignment",
"BlankLine",
"CodeBlock",
"Comment",
"FormatContext",
"PathCollector",
"PathConstructor",
"PathFactoring",
"PathRendering",
"PythonSourceLiteral",
"SourceFormatter",
"SourceFragment",
"generate_python_source",
"imported_name",
"resolve_imports",
"to_source",
]
Loading
Loading