Skip to content

Commit da7ba35

Browse files
committed
Fix list/dict constant resolution and numeric key handling
Runtime: auto-promoted constants containing wired refs (e.g. ["{obj1}", "{obj2}"]) now resolve correctly via _resolve_runtime instead of crashing in _format_string_constant. Formats: all three input adapters (toml_to_spec, json_to_spec, ini_to_spec) now convert top-level numeric string keys to integers for positional argument support. Conversion is shallow — nested dicts keep string keys, since _separate_args_kwargs only dispatches on top-level keys. spec_to_toml converts int keys back to strings. Uses Mapping for input params to avoid dict invariance casts.
1 parent 7bbf8a6 commit da7ba35

5 files changed

Lines changed: 373 additions & 30 deletions

File tree

apywire/compiler.py

Lines changed: 126 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from apywire.constants import (
1616
CACHE_ATTR_PREFIX,
17+
PLACEHOLDER_REGEX,
1718
SYNTHETIC_CONST,
1819
)
1920
from apywire.wiring import (
@@ -258,9 +259,7 @@ def _compile_property(
258259

259260
if not thread_safe:
260261
assign_cache = self._create_cache_assignment(cache_attr, call)
261-
if_stmt = ast.If(
262-
test=has_check, body=[assign_cache], orelse=[]
263-
)
262+
if_stmt = ast.If(test=has_check, body=[assign_cache], orelse=[])
264263
func_def = ast.FunctionDef(
265264
name=name,
266265
args=_PROPERTY_ARGS,
@@ -282,9 +281,7 @@ def _compile_property(
282281
args=[ast.Constant(value=name), maker],
283282
keywords=[],
284283
)
285-
assign_cache = self._create_cache_assignment(
286-
cache_attr, call_inst
287-
)
284+
assign_cache = self._create_cache_assignment(cache_attr, call_inst)
288285
func_def = ast.FunctionDef(
289286
name=name,
290287
args=_PROPERTY_ARGS,
@@ -321,6 +318,119 @@ def _compile_constant_property(
321318
type_params=[],
322319
)
323320

321+
def _astify_interpolated_string(self, template: str) -> ast.expr:
322+
"""Build an AST f-string from a template with placeholders.
323+
324+
Turns ``"Hello {name}"`` into ``f"Hello {str(self.name())}"``.
325+
Each ``{ref}`` becomes a ``FormattedValue`` calling the
326+
accessor and converting to ``str()``.
327+
"""
328+
parts: list[ast.expr | ast.Constant] = []
329+
last_end = 0
330+
for match in PLACEHOLDER_REGEX.finditer(template):
331+
# Add literal text before this placeholder
332+
if match.start() > last_end:
333+
parts.append(ast.Constant(template[last_end : match.start()]))
334+
# Add formatted value: str(self.name())
335+
ref_name = match.group(1)
336+
accessor_call = ast.Call(
337+
func=ast.Attribute(
338+
value=ast.Name(id="self", ctx=ast.Load()),
339+
attr=ref_name,
340+
ctx=ast.Load(),
341+
),
342+
args=[],
343+
keywords=[],
344+
)
345+
str_call = ast.Call(
346+
func=ast.Name(id="str", ctx=ast.Load()),
347+
args=[accessor_call],
348+
keywords=[],
349+
)
350+
parts.append(
351+
ast.FormattedValue(
352+
value=str_call,
353+
conversion=-1,
354+
format_spec=None,
355+
)
356+
)
357+
last_end = match.end()
358+
# Add trailing literal
359+
if last_end < len(template):
360+
parts.append(ast.Constant(template[last_end:]))
361+
return ast.JoinedStr(values=parts)
362+
363+
def _compile_promoted_constant(
364+
self,
365+
name: str,
366+
data: _ResolvedValue,
367+
*,
368+
thread_safe: bool = False,
369+
) -> ast.FunctionDef:
370+
"""Build an AST FunctionDef for an auto-promoted constant.
371+
372+
Auto-promoted constants are values (strings, lists, dicts)
373+
that reference wired objects via placeholders. They are
374+
compiled as cached accessors that resolve wired refs at
375+
call time.
376+
"""
377+
if isinstance(data, str):
378+
value_expr = self._astify_interpolated_string(data)
379+
else:
380+
value_expr = self._astify(data)
381+
cache_attr = f"{CACHE_ATTR_PREFIX}{name}"
382+
has_check = self._create_cache_check(cache_attr)
383+
return_stmt = self._create_return_statement(cache_attr)
384+
385+
if not thread_safe:
386+
assign_cache = self._create_cache_assignment(
387+
cache_attr, value_expr
388+
)
389+
return ast.FunctionDef(
390+
name=name,
391+
args=_PROPERTY_ARGS,
392+
body=[
393+
ast.If(
394+
test=has_check,
395+
body=[assign_cache],
396+
orelse=[],
397+
),
398+
return_stmt,
399+
],
400+
decorator_list=[],
401+
returns=None,
402+
type_comment=None,
403+
type_params=[],
404+
)
405+
else:
406+
maker = self._create_lambda_function(value_expr)
407+
call_inst = ast.Call(
408+
func=ast.Attribute(
409+
value=ast.Name(id="self", ctx=ast.Load()),
410+
attr="_instantiate_attr",
411+
ctx=ast.Load(),
412+
),
413+
args=[ast.Constant(value=name), maker],
414+
keywords=[],
415+
)
416+
assign_cache = self._create_cache_assignment(cache_attr, call_inst)
417+
return ast.FunctionDef(
418+
name=name,
419+
args=_PROPERTY_ARGS,
420+
body=[
421+
ast.If(
422+
test=has_check,
423+
body=[assign_cache],
424+
orelse=[],
425+
),
426+
return_stmt,
427+
],
428+
decorator_list=[],
429+
returns=None,
430+
type_comment=None,
431+
type_params=[],
432+
)
433+
324434
def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
325435
"""Compiles the Spec into a string containing Python code.
326436
@@ -423,9 +533,7 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
423533
for cname, cvalue in constant_names.items():
424534
cache_attr = f"{CACHE_ATTR_PREFIX}{cname}"
425535
init_body.append(
426-
self._create_cache_assignment(
427-
cache_attr, ast.Constant(cvalue)
428-
)
536+
self._create_cache_assignment(cache_attr, ast.Constant(cvalue))
429537
)
430538
if init_body:
431539
init_def = ast.FunctionDef(
@@ -439,12 +547,19 @@ def compile(self, *, aio: bool = False, thread_safe: bool = False) -> str:
439547
class_body.insert(0, init_def)
440548

441549
for name, entry in self._parsed.items():
442-
# Skip synthetic auto-promoted constants
443-
# These require runtime interpolation and can't be pre-computed
444550
if (
445551
entry.module_name == SYNTHETIC_CONST
446552
and entry.class_name == "str"
447553
):
554+
# Auto-promoted constant with wired refs.
555+
# Compile as a cached accessor that resolves refs.
556+
class_body.append(
557+
self._compile_promoted_constant(
558+
name,
559+
entry.data,
560+
thread_safe=thread_safe,
561+
)
562+
)
448563
continue
449564

450565
class_body.append(

apywire/formats.py

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import configparser
1010
import json
1111
from types import ModuleType
12+
from collections.abc import Mapping
1213
from typing import cast
1314

1415
from apywire.exceptions import FormatError
@@ -151,7 +152,10 @@ def ini_to_spec(content: str) -> Spec:
151152
value = config[section][key]
152153
parsed = _parse_ini_value(value)
153154
section_dict[key] = cast(_ConstantValue, parsed)
154-
spec[section] = cast(_SpecMapping, section_dict)
155+
spec[section] = cast(
156+
_SpecMapping,
157+
_convert_numeric_keys(section_dict),
158+
)
155159
return spec
156160

157161

@@ -181,7 +185,10 @@ def spec_to_toml(spec: Spec) -> str:
181185
raise FormatError("toml", "TOML output requires tomli_w.")
182186
toml_dict: dict[str, object] = {}
183187
for key, value in spec.items():
184-
toml_dict[key] = value
188+
if isinstance(value, dict):
189+
toml_dict[key] = _stringify_int_keys(value)
190+
else:
191+
toml_dict[key] = value
185192
result: str = _tomli_w.dumps(toml_dict)
186193
return result
187194

@@ -219,12 +226,50 @@ def toml_to_spec(content: str) -> Spec:
219226
spec: Spec = {}
220227
for key, value in data.items():
221228
if isinstance(value, dict):
222-
spec[key] = cast(_SpecMapping, value)
229+
spec[key] = cast(_SpecMapping, _convert_numeric_keys(value))
223230
else:
224231
spec[key] = cast(_ConstantValue, value)
225232
return spec
226233

227234

235+
def _stringify_int_keys(
236+
d: Mapping[str | int, object],
237+
) -> dict[str, object]:
238+
"""Convert top-level integer keys back to strings for serialization.
239+
240+
The inverse of ``_convert_numeric_keys``. Only converts the
241+
top level — nested dicts are left unchanged because only
242+
top-level int keys represent positional arguments.
243+
"""
244+
return {str(k): v for k, v in d.items()}
245+
246+
247+
def _convert_numeric_keys(
248+
d: Mapping[str | int, object],
249+
) -> dict[str | int, object]:
250+
"""Convert top-level numeric string keys to integers.
251+
252+
File formats (TOML, JSON, INI) use string keys, but apywire
253+
uses integer keys for positional arguments. This converts
254+
keys like ``"0"``, ``"1"`` to ``0``, ``1``.
255+
256+
Only converts the top level of the dict — nested dicts keep
257+
their string keys, since ``_separate_args_kwargs`` only checks
258+
the top level for positional vs keyword argument dispatch.
259+
"""
260+
result: dict[str | int, object] = {}
261+
for k, v in d.items():
262+
# Only convert canonical decimal representations (no leading
263+
# zeros) to avoid "00"/"01" silently colliding with "0"/"1".
264+
new_key: str | int
265+
if isinstance(k, str) and k.isdigit() and str(int(k)) == k:
266+
new_key = int(k)
267+
else:
268+
new_key = k
269+
result[new_key] = v
270+
return result
271+
272+
228273
def spec_to_json(spec: Spec) -> str:
229274
"""Convert a spec dict to JSON format string.
230275
@@ -264,8 +309,24 @@ def json_to_spec(content: str) -> Spec:
264309
2025
265310
"""
266311
try:
267-
result: Spec = json.loads(content)
312+
raw: object = json.loads(content)
268313
except Exception as e:
269-
raise FormatError("json", f"Failed to parse JSON content: {e}") from e
270-
271-
return result
314+
raise FormatError(
315+
"json", f"Failed to parse JSON content: {e}"
316+
) from e
317+
318+
if not isinstance(raw, dict):
319+
raise FormatError(
320+
"json",
321+
"JSON root must be an object, "
322+
f"got {type(raw).__name__}",
323+
)
324+
325+
data: dict[str, object] = raw
326+
spec: Spec = {}
327+
for key, value in data.items():
328+
if isinstance(value, dict):
329+
spec[key] = cast(_SpecMapping, _convert_numeric_keys(value))
330+
else:
331+
spec[key] = cast(_ConstantValue, value)
332+
return spec

apywire/runtime.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,15 @@ def _instantiate_impl(self, name: str) -> _RuntimeValue:
198198

199199
# Check for synthetic auto-promoted constant
200200
if entry.module_name == SYNTHETIC_CONST and entry.class_name == "str":
201-
# This is an auto-promoted constant with string interpolation
202-
value = self._format_string_constant(entry.data, context=name)
203-
self._values[name] = value
204-
return value
201+
result: _RuntimeValue
202+
if isinstance(entry.data, str):
203+
# String template with interpolation
204+
result = self._format_string_constant(entry.data, context=name)
205+
else:
206+
# Non-string (list, dict, tuple) with wired refs
207+
result = self._resolve_runtime(entry.data, context=name)
208+
self._values[name] = result
209+
return result
205210

206211
module = importlib.import_module(entry.module_name)
207212
cls = cast(_Constructor, getattr(module, entry.class_name))

0 commit comments

Comments
 (0)