cs-object 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.
cso_python/bindings.py ADDED
@@ -0,0 +1,168 @@
1
+ """Generate runtime handles and type stubs from static calculation definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import keyword
8
+ import os
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from .definitions import Definitions
13
+ from .source import Capture, SourceError, authoring_name
14
+
15
+
16
+ def generate(directory: Path, *, check: bool = False) -> dict:
17
+ root = directory.resolve(strict=True)
18
+ sources = sorted(root.rglob("*.cso.py"))
19
+ if not sources:
20
+ raise SourceError("MISSING_SOURCE", "No .cso.py calculations found")
21
+ if not root.is_dir():
22
+ raise SourceError("INVALID_DEPENDENCY_PATH", "Bindings require a directory")
23
+ output = root / "_cso_bindings"
24
+ if output.is_symlink() or any(path.is_symlink() for path in output.rglob("*")):
25
+ raise SourceError(
26
+ "INVALID_DEPENDENCY_PATH",
27
+ "Generated bindings cannot contain symbolic links",
28
+ )
29
+ files: dict[Path, str] = {output / "__init__.py": "", output / "py.typed": ""}
30
+ for source in sources:
31
+ relative = source.relative_to(root)
32
+ parts = [*relative.parts[:-1], relative.name[:-7]]
33
+ if any(not part.isidentifier() or keyword.iskeyword(part) for part in parts):
34
+ raise SourceError(
35
+ "INVALID_BINDING_NAME",
36
+ f"Binding module needs identifier path components: {relative}",
37
+ )
38
+ capture = Capture(source)
39
+ capture.root = root
40
+ capture.for_bindings = True
41
+ module = capture.load(source)
42
+ definitions = Definitions(capture)
43
+ runtime = [
44
+ "# Generated by cso bindings. Do not edit.\n",
45
+ "from cso_python import load_calculation\n\n",
46
+ ]
47
+ stub = [
48
+ "# Generated by cso bindings. Do not edit.\n",
49
+ "from typing import TypedDict\n\n",
50
+ ]
51
+ target = output.joinpath(*parts).with_suffix(".py")
52
+ parent = target.parent
53
+ while parent != output:
54
+ files[parent / "__init__.py"] = ""
55
+ parent = parent.parent
56
+ names = set(module.functions)
57
+ reserved = {"TypedDict", "load_calculation"} | {
58
+ f"_{name}_Outputs" for name in names
59
+ }
60
+ if names & reserved:
61
+ raise SourceError(
62
+ "BINDING_OUTPUT_CONFLICT",
63
+ f"Calculation names collide with binding helpers: {sorted(names & reserved)}",
64
+ )
65
+ for name, fn in module.functions.items():
66
+ if not any(
67
+ authoring_name(d.func) == "calculation" for d in fn.decorator_list
68
+ ):
69
+ continue
70
+ definition = definitions.get(source, name)
71
+ path = os.path.relpath(source, target.parent)
72
+ runtime.append(
73
+ f"{name} = load_calculation({path!r}, function={name!r}, fingerprint={definition.fingerprint!r})\n"
74
+ )
75
+ output_type = f"_{name}_Outputs"
76
+ fields = ", ".join(
77
+ f"{key!r}: {spec.annotation.numeric_type}"
78
+ for key, spec in definition.outputs.items()
79
+ )
80
+ stub.append(f"{output_type} = TypedDict({output_type!r}, {{{fields}}})\n")
81
+ parameters = ", ".join(
82
+ f"{key}: {spec.declared.numeric_type}"
83
+ + (f" = {spec.default_value!r}" if spec.default is not None else "")
84
+ for key, spec in definition.parameters.items()
85
+ )
86
+ stub.append(
87
+ f"def {name}({('*, ' + parameters) if parameters else ''}) -> {output_type}: ...\n\n"
88
+ )
89
+ if target in files or target.with_suffix(".pyi") in files:
90
+ raise SourceError(
91
+ "BINDING_OUTPUT_CONFLICT", f"Colliding binding module {target}"
92
+ )
93
+ if target.name == "__init__.py":
94
+ raise SourceError(
95
+ "BINDING_OUTPUT_CONFLICT",
96
+ "__init__.cso.py collides with a generated package",
97
+ )
98
+ files[target] = "".join(runtime)
99
+ files[target.with_suffix(".pyi")] = "".join(stub)
100
+ expected = set(files)
101
+ existing = (
102
+ {p for p in output.rglob("*") if p.is_file() and "__pycache__" not in p.parts}
103
+ if output.exists()
104
+ else set()
105
+ )
106
+ stale = [
107
+ str(p.relative_to(root))
108
+ for p in sorted(expected | existing)
109
+ if p not in files or not p.exists() or p.read_text() != files[p]
110
+ ]
111
+ if not check:
112
+ # Check ownership before replacing or removing an existing generated file.
113
+ for path in existing:
114
+ if not (
115
+ path.name in ("__init__.py", "py.typed") and not path.read_bytes()
116
+ ) and not path.read_text().startswith("# Generated by cso bindings."):
117
+ raise SourceError(
118
+ "BINDING_OUTPUT_CONFLICT",
119
+ f"Refusing to overwrite authored file {path}",
120
+ )
121
+ for path, content in files.items():
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ descriptor, temporary = tempfile.mkstemp(
124
+ dir=path.parent, prefix=".cso-bindings-"
125
+ )
126
+ try:
127
+ with os.fdopen(descriptor, "w") as stream:
128
+ stream.write(content)
129
+ os.replace(temporary, path)
130
+ finally:
131
+ if os.path.exists(temporary):
132
+ os.unlink(temporary)
133
+ for path in existing - expected:
134
+ path.unlink()
135
+ return {
136
+ "ok": not check or not stale,
137
+ "command": "bindings",
138
+ "check": check,
139
+ "directory": str(root),
140
+ "files": [str(p.relative_to(root)) for p in sorted(files)],
141
+ "stale": stale,
142
+ }
143
+
144
+
145
+ def bindings_from_argv(argv: list[str]) -> int:
146
+ parser = argparse.ArgumentParser(
147
+ prog="python -m cso_python bindings", description=__doc__
148
+ )
149
+ parser.add_argument("directory", type=Path)
150
+ parser.add_argument(
151
+ "--check",
152
+ action="store_true",
153
+ help="Report stale bindings without writing files",
154
+ )
155
+ args = parser.parse_args(argv)
156
+ try:
157
+ result = generate(args.directory, check=args.check)
158
+ except (SourceError, OSError, ValueError) as error:
159
+ result = {
160
+ "ok": False,
161
+ "diagnostics": [
162
+ error.diagnostic
163
+ if isinstance(error, SourceError)
164
+ else {"code": "BINDINGS_FAILED", "message": str(error)}
165
+ ],
166
+ }
167
+ print(json.dumps(result, ensure_ascii=False, allow_nan=False))
168
+ return 0 if result["ok"] else 1
@@ -0,0 +1,414 @@
1
+ """Static public interfaces shared by generation and invocation planning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import hashlib
7
+ from dataclasses import dataclass, replace
8
+ from pathlib import Path
9
+
10
+ from .annotations import Annotation, annotation, symbol_annotation
11
+ from .source import (
12
+ Capture,
13
+ CapturedModule,
14
+ Json,
15
+ SourceError,
16
+ authoring_name,
17
+ compact,
18
+ literal,
19
+ number,
20
+ span,
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ParameterDefinition:
26
+ """Keep declared input behavior separate from legacy given-row documentation."""
27
+
28
+ node: ast.arg
29
+ declared: Annotation
30
+ documented: Annotation
31
+ default: ast.expr | None
32
+ default_value: int | float | None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class OutputDefinition:
37
+ annotation: Annotation
38
+ selection: ast.expr
39
+ parameter_source: str | None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Definition:
44
+ module: CapturedModule
45
+ function: ast.FunctionDef
46
+ parameters: dict[str, ParameterDefinition]
47
+ outputs: dict[str, OutputDefinition]
48
+ fingerprint: str
49
+ given_parameters: frozenset[str]
50
+ legacy_return: bool
51
+
52
+
53
+ class Definitions:
54
+ def __init__(self, capture: Capture):
55
+ self.capture = capture
56
+ self.cache: dict[tuple[Path, str], Definition] = {}
57
+ self.visiting: set[tuple[Path, str]] = set()
58
+
59
+ def get(self, path: Path, function: str) -> Definition:
60
+ key = (path.resolve(), function)
61
+ if key in self.cache:
62
+ return self.cache[key]
63
+ if key in self.visiting:
64
+ raise SourceError("DEPENDENCY_CYCLE", f"Recursive calculation {function}")
65
+ self.visiting.add(key)
66
+ try:
67
+ definition = self._read(self.capture.load(path), function)
68
+ self.cache[key] = definition
69
+ return definition
70
+ finally:
71
+ self.visiting.remove(key)
72
+
73
+ def _read(self, module: CapturedModule, function: str) -> Definition:
74
+ fn = module.functions.get(function)
75
+ if fn is None:
76
+ raise SourceError("MISSING_FUNCTION", f"Missing calculation {function}")
77
+ if fn.args.posonlyargs or fn.args.vararg or fn.args.kwarg:
78
+ raise SourceError(
79
+ "UNSUPPORTED_SIGNATURE",
80
+ "Use named positional or keyword-only parameters",
81
+ location=span(module.id, fn),
82
+ )
83
+ parameter_nodes = [*fn.args.args, *fn.args.kwonlyargs]
84
+ nodes = {a.arg: a for a in parameter_nodes}
85
+ if len(nodes) != len(parameter_nodes):
86
+ raise SourceError(
87
+ "UNSUPPORTED_SIGNATURE",
88
+ "Parameter names must be unique",
89
+ location=span(module.id, fn),
90
+ )
91
+ if nodes.keys() & module.imported or any(
92
+ name.startswith("__cso_") for name in nodes
93
+ ):
94
+ raise SourceError(
95
+ "UNSUPPORTED_SIGNATURE",
96
+ "Parameters cannot shadow imported authoring helpers or math names",
97
+ location=span(module.id, fn),
98
+ )
99
+ defaults = (
100
+ dict(
101
+ zip(
102
+ [a.arg for a in fn.args.args][-len(fn.args.defaults) :],
103
+ fn.args.defaults,
104
+ )
105
+ )
106
+ if fn.args.defaults
107
+ else {}
108
+ )
109
+ defaults.update(
110
+ {
111
+ a.arg: d
112
+ for a, d in zip(fn.args.kwonlyargs, fn.args.kw_defaults)
113
+ if d is not None
114
+ }
115
+ )
116
+ parameters = {}
117
+ for name, node in nodes.items():
118
+ if node.annotation is None:
119
+ raise SourceError(
120
+ "UNSUPPORTED_SIGNATURE",
121
+ "Parameters need numeric annotations",
122
+ location=span(module.id, node),
123
+ )
124
+ declared = annotation(node.annotation, module.id, module.aliases)
125
+ default = defaults.get(name)
126
+ parameters[name] = ParameterDefinition(
127
+ node=node,
128
+ declared=declared,
129
+ documented=declared,
130
+ default=default,
131
+ default_value=(
132
+ number(literal(default, module.id), span(module.id, default))
133
+ if default is not None
134
+ else None
135
+ ),
136
+ )
137
+ legacy_signature = all(p.declared.metadata is None for p in parameters.values())
138
+ documented_names: list[str] = []
139
+ known = {name: p.declared for name, p in parameters.items()}
140
+ children: dict[str, dict[str, Annotation]] = {}
141
+ forwarded: dict[str, dict[str, str]] = {}
142
+ assigned: set[str] = set()
143
+ given_parameters: set[str] = set()
144
+ outputs: dict[str, OutputDefinition] = {}
145
+ returned = False
146
+ legacy_return = False
147
+
148
+ def reference(expr: ast.expr) -> Annotation:
149
+ if isinstance(expr, ast.Name) and expr.id in known:
150
+ return known[expr.id]
151
+ if (
152
+ isinstance(expr, ast.Subscript)
153
+ and isinstance(expr.value, ast.Name)
154
+ and expr.value.id in children
155
+ and isinstance(expr.slice, ast.Constant)
156
+ and isinstance(expr.slice.value, str)
157
+ ):
158
+ output = children[expr.value.id].get(expr.slice.value)
159
+ if output is not None:
160
+ return output
161
+ raise SourceError(
162
+ "MISSING_OUTPUT",
163
+ "Public outputs must select documented quantities",
164
+ location=span(module.id, expr),
165
+ )
166
+
167
+ def parameter_source(expr: ast.expr) -> str | None:
168
+ if (
169
+ isinstance(expr, ast.Name)
170
+ and expr.id in parameters
171
+ and expr.id not in assigned
172
+ ):
173
+ return expr.id
174
+ if (
175
+ isinstance(expr, ast.Subscript)
176
+ and isinstance(expr.value, ast.Name)
177
+ and isinstance(expr.slice, ast.Constant)
178
+ and isinstance(expr.slice.value, str)
179
+ ):
180
+ return forwarded.get(expr.value.id, {}).get(expr.slice.value)
181
+ return None
182
+
183
+ def dependency(path: Path, function: str, call: ast.Assign) -> Definition:
184
+ try:
185
+ return self.get(path, function)
186
+ except SourceError as error:
187
+ error.diagnostic.setdefault("callChain", []).insert(
188
+ 0, span(module.id, call)
189
+ )
190
+ raise
191
+
192
+ def bind_child(
193
+ name: str, child: Definition, arguments: dict[str, ast.expr]
194
+ ) -> None:
195
+ children[name] = {
196
+ key: output.annotation for key, output in child.outputs.items()
197
+ }
198
+ forwarded[name] = {}
199
+ for output, selection in child.outputs.items():
200
+ if selection.parameter_source is None:
201
+ continue
202
+ argument = arguments.get(selection.parameter_source)
203
+ if argument is None or isinstance(
204
+ argument, (ast.Constant, ast.UnaryOp)
205
+ ):
206
+ continue
207
+ children[name][output] = reference(argument)
208
+ parent_parameter = parameter_source(argument)
209
+ if parent_parameter:
210
+ forwarded[name][output] = parent_parameter
211
+
212
+ def statements(body: list[ast.stmt], *, grouped: bool = False) -> None:
213
+ nonlocal returned, legacy_return
214
+ for index, node in enumerate(body):
215
+ if isinstance(node, ast.AnnAssign) and isinstance(
216
+ node.target, ast.Name
217
+ ):
218
+ if node.target.id in assigned:
219
+ raise SourceError(
220
+ "DUPLICATE_IDENTITY",
221
+ "Symbols need unique simple names",
222
+ location=span(module.id, node),
223
+ )
224
+ assigned.add(node.target.id)
225
+ documented_names.append(node.target.id)
226
+ declared = symbol_annotation(node, module.id, module.aliases)
227
+ known[node.target.id] = declared
228
+ if (
229
+ isinstance(node.value, ast.Call)
230
+ and authoring_name(node.value.func) == "given"
231
+ and len(node.value.args) == 1
232
+ and isinstance(node.value.args[0], ast.Name)
233
+ ):
234
+ parameter = node.value.args[0].id
235
+ if parameter in parameters:
236
+ original = parameters[parameter]
237
+ if (
238
+ original.declared.metadata is not None
239
+ or parameter in given_parameters
240
+ ):
241
+ raise SourceError(
242
+ "AMBIGUOUS_METADATA",
243
+ "A parameter must have one metadata declaration",
244
+ location=span(module.id, original.node),
245
+ )
246
+ given_parameters.add(parameter)
247
+ parameters[parameter] = replace(
248
+ original, documented=declared
249
+ )
250
+ elif (
251
+ isinstance(node, ast.Assign)
252
+ and isinstance(node.value, ast.Call)
253
+ and len(node.targets) == 1
254
+ and isinstance(node.targets[0], ast.Name)
255
+ ):
256
+ call = node.value
257
+ name = authoring_name(call.func)
258
+ if name in module.handles:
259
+ handle = module.handles[name]
260
+ child = dependency(handle["path"], handle["function"], node)
261
+ if (
262
+ handle["fingerprint"] is not None
263
+ and handle["fingerprint"] != child.fingerprint
264
+ ):
265
+ raise SourceError(
266
+ "STALE_BINDINGS",
267
+ "Run cso bindings to refresh the changed public interface",
268
+ location=span(module.id, call),
269
+ )
270
+ bind_child(
271
+ node.targets[0].id,
272
+ child,
273
+ {
274
+ kw.arg: kw.value
275
+ for kw in call.keywords
276
+ if kw.arg is not None
277
+ },
278
+ )
279
+ elif name == "calculation_call":
280
+ kw = {k.arg: k.value for k in call.keywords}
281
+ if call.args and "function" in kw:
282
+ path = literal(call.args[0], module.id)
283
+ function_name = literal(kw["function"], module.id)
284
+ if (
285
+ not isinstance(path, str)
286
+ or not path
287
+ or Path(path).is_absolute()
288
+ or "\\" in path
289
+ or ":" in path
290
+ or not isinstance(function_name, str)
291
+ or not function_name.isidentifier()
292
+ ):
293
+ raise SourceError(
294
+ "INVALID_CALL",
295
+ "Use a static relative path and function name",
296
+ location=span(module.id, call),
297
+ )
298
+ child = dependency(
299
+ module.path.parent / path, function_name, node
300
+ )
301
+ inputs = kw.get("inputs")
302
+ if isinstance(inputs, ast.Dict):
303
+ arguments = {}
304
+ for key, value in zip(inputs.keys, inputs.values):
305
+ if (
306
+ not isinstance(key, ast.Constant)
307
+ or not isinstance(key.value, str)
308
+ or key.value in arguments
309
+ ):
310
+ raise SourceError(
311
+ "INVALID_CALL_INPUTS",
312
+ "Duplicate call input or nonliteral key",
313
+ location=span(module.id, inputs),
314
+ related=[
315
+ span(child.module.id, child.function)
316
+ ],
317
+ )
318
+ arguments[key.value] = value
319
+ bind_child(node.targets[0].id, child, arguments)
320
+ elif isinstance(node, ast.With):
321
+ statements(node.body, grouped=True)
322
+ elif isinstance(node, ast.Return):
323
+ if grouped:
324
+ raise SourceError(
325
+ "UNSUPPORTED_SYNTAX",
326
+ "Document groups cannot contain returns",
327
+ location=span(module.id, node),
328
+ )
329
+ if index != len(body) - 1:
330
+ raise SourceError(
331
+ "UNSUPPORTED_SYNTAX",
332
+ "Statements after return are unsupported",
333
+ location=span(module.id, body[index + 1]),
334
+ )
335
+ if not isinstance(node.value, ast.Dict):
336
+ raise SourceError(
337
+ "INVALID_RETURN_KEYS",
338
+ "Return a literal-key result dictionary",
339
+ location=span(module.id, node),
340
+ )
341
+ keys = [
342
+ key.value
343
+ if isinstance(key, ast.Constant) and isinstance(key.value, str)
344
+ else None
345
+ for key in node.value.keys
346
+ ]
347
+ if any(key is None or not key for key in keys) or len(
348
+ set(keys)
349
+ ) != len(keys):
350
+ raise SourceError(
351
+ "INVALID_RETURN_KEYS",
352
+ "Return unique nonempty literal output names",
353
+ location=span(module.id, node),
354
+ )
355
+ # Preserve source order, including assignments inside document groups.
356
+ # Complete legacy maps keep incorrect runtime returns independently observable.
357
+ legacy_return = legacy_signature and keys == documented_names
358
+ for key, value in zip(keys, node.value.values):
359
+ outputs[key] = OutputDefinition(
360
+ annotation=known[key]
361
+ if legacy_return
362
+ else reference(value),
363
+ selection=value,
364
+ parameter_source=parameter_source(value),
365
+ )
366
+ returned = True
367
+
368
+ statements(fn.body)
369
+ if not returned:
370
+ raise SourceError(
371
+ "INVALID_RETURN_KEYS",
372
+ "Calculation needs one final return dictionary",
373
+ location=span(module.id, fn),
374
+ )
375
+ interface: Json = {
376
+ "function": function,
377
+ "parameters": [
378
+ {
379
+ "name": name,
380
+ "type": spec.declared.numeric_type,
381
+ **(
382
+ {"documentedType": spec.documented.numeric_type}
383
+ if spec.documented.numeric_type != spec.declared.numeric_type
384
+ else {}
385
+ ),
386
+ "metadata": spec.documented.metadata,
387
+ **(
388
+ {"default": spec.default_value}
389
+ if spec.default is not None
390
+ else {}
391
+ ),
392
+ "keywordOnly": name in {a.arg for a in fn.args.kwonlyargs},
393
+ }
394
+ for name, spec in parameters.items()
395
+ ],
396
+ "outputs": [
397
+ {
398
+ "name": name,
399
+ "selection": ast.dump(spec.selection, include_attributes=False),
400
+ "type": spec.annotation.numeric_type,
401
+ "metadata": spec.annotation.metadata,
402
+ }
403
+ for name, spec in outputs.items()
404
+ ],
405
+ }
406
+ return Definition(
407
+ module=module,
408
+ function=fn,
409
+ parameters=parameters,
410
+ outputs=outputs,
411
+ fingerprint=hashlib.sha256(compact(interface).encode()).hexdigest(),
412
+ given_parameters=frozenset(given_parameters),
413
+ legacy_return=legacy_return,
414
+ )