morphir-python 0.0.2__tar.gz → 0.1.0__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.
@@ -73,3 +73,9 @@ yarn-error.log*
73
73
 
74
74
  # behave writes the pretty formatter to this file when its output is piped
75
75
  pretty.output
76
+
77
+ # Rust
78
+ rust/target/
79
+
80
+ # Extension bundles built by mise run extension:bundle
81
+ .morphir/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: morphir-python
3
- Version: 0.0.2
3
+ Version: 0.1.0
4
4
  Summary: CLI tools and extensions for Morphir Python
5
5
  Project-URL: Homepage, https://github.com/finos/morphir-python
6
6
  Project-URL: Documentation, https://morphir.finos.org
@@ -20,7 +20,8 @@ Classifier: Topic :: Software Development :: Code Generators
20
20
  Classifier: Topic :: Software Development :: Libraries
21
21
  Classifier: Typing :: Typed
22
22
  Requires-Python: >=3.14
23
- Requires-Dist: morphir>=0.0.1
23
+ Requires-Dist: morphir-python-native==0.1.1
24
+ Requires-Dist: morphir>=0.1.0
24
25
  Requires-Dist: pygments>=2.20.0
25
26
  Requires-Dist: typer>=0.15
26
27
  Description-Content-Type: text/markdown
@@ -66,6 +67,7 @@ morphir-python ir inspect morphir-ir.json
66
67
  morphir-python ir convert morphir-ir.json --to v4 -o out.json --package-version 1.2.0
67
68
  morphir-python ir convert v4.json --to v3 -o out.json --drop-source-locations
68
69
  morphir-python ir convert old-format-1.json --to v3 -o out.json
70
+ morphir-python ir convert morphir-ir.json --to v4 --v4-spelling expanded -o out.json
69
71
  ```
70
72
 
71
73
  `inspect` prints the normalized format version, the distribution kind
@@ -74,6 +76,9 @@ draft and formats 1 and 2 as legacy. `convert` handles `Library` and `Specs` in
74
76
  both directions. It writes a v3 `Library` as format `3` and a v3 `Specs` as
75
77
  `"3.1.0"`, the lowest version that holds each. A legacy format 1 or 2 file
76
78
  converts to a format 3.0.0 `Library` or draft v4; nothing writes format 1 or 2.
79
+ Draft v4 is written in the compact canonical spelling, as other Morphir tools
80
+ write it; `--v4-spelling expanded` writes every node in the expanded form
81
+ instead. Both spellings read back to the same model.
77
82
  `convert` writes through a temporary file and renames it only on success, so a
78
83
  failed conversion leaves no output file.
79
84
  Diagnostics go to standard error as `code at location: message`.
@@ -86,7 +91,7 @@ format-version rules and the draft v4 status.
86
91
  ## Requirements
87
92
 
88
93
  - Python 3.14+
89
- - `morphir>=0.0.1` (installed as a dependency)
94
+ - `morphir>=0.1.0` and `morphir-python-native==0.1.1` (installed as dependencies)
90
95
 
91
96
  ## License
92
97
 
@@ -39,6 +39,7 @@ morphir-python ir inspect morphir-ir.json
39
39
  morphir-python ir convert morphir-ir.json --to v4 -o out.json --package-version 1.2.0
40
40
  morphir-python ir convert v4.json --to v3 -o out.json --drop-source-locations
41
41
  morphir-python ir convert old-format-1.json --to v3 -o out.json
42
+ morphir-python ir convert morphir-ir.json --to v4 --v4-spelling expanded -o out.json
42
43
  ```
43
44
 
44
45
  `inspect` prints the normalized format version, the distribution kind
@@ -47,6 +48,9 @@ draft and formats 1 and 2 as legacy. `convert` handles `Library` and `Specs` in
47
48
  both directions. It writes a v3 `Library` as format `3` and a v3 `Specs` as
48
49
  `"3.1.0"`, the lowest version that holds each. A legacy format 1 or 2 file
49
50
  converts to a format 3.0.0 `Library` or draft v4; nothing writes format 1 or 2.
51
+ Draft v4 is written in the compact canonical spelling, as other Morphir tools
52
+ write it; `--v4-spelling expanded` writes every node in the expanded form
53
+ instead. Both spellings read back to the same model.
50
54
  `convert` writes through a temporary file and renames it only on success, so a
51
55
  failed conversion leaves no output file.
52
56
  Diagnostics go to standard error as `code at location: message`.
@@ -59,7 +63,7 @@ format-version rules and the draft v4 status.
59
63
  ## Requirements
60
64
 
61
65
  - Python 3.14+
62
- - `morphir>=0.0.1` (installed as a dependency)
66
+ - `morphir>=0.1.0` and `morphir-python-native==0.1.1` (installed as dependencies)
63
67
 
64
68
  ## License
65
69
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "morphir-python"
3
- version = "0.0.2"
3
+ version = "0.1.0"
4
4
  description = "CLI tools and extensions for Morphir Python"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -22,7 +22,9 @@ authors = [
22
22
  { name = "FINOS", email = "morphir@finos.org" }
23
23
  ]
24
24
  dependencies = [
25
- "morphir>=0.0.1",
25
+ "morphir>=0.1.0",
26
+ # Exact qualified native compiler; checked again when the compiler first loads.
27
+ "morphir-python-native==0.1.1",
26
28
  "typer>=0.15",
27
29
  # Rich uses Pygments; older versions are affected by CVE-2026-4539.
28
30
  "pygments>=2.20.0",
@@ -6,8 +6,8 @@ Morphir domain models, including code generation and development tools.
6
6
  Example:
7
7
  >>> import morphir_tools
8
8
  >>> morphir_tools.__version__
9
- '0.0.2'
9
+ '0.1.0'
10
10
  """
11
11
 
12
- __version__ = "0.0.2"
12
+ __version__ = "0.1.0"
13
13
  __all__ = ["__version__"]
@@ -0,0 +1,114 @@
1
+ """Safe publication of generated files.
2
+
3
+ Generation writes a complete tree into a new sibling staging directory and only
4
+ then moves it into place, so a failure never leaves partial output or touches
5
+ existing user files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ from pathlib import Path, PurePosixPath
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Iterable, Sequence
18
+
19
+
20
+ def umask() -> int:
21
+ """Return the process umask without changing it."""
22
+ mask = os.umask(0o022)
23
+ os.umask(mask)
24
+ return mask
25
+
26
+
27
+ class OutputError(Exception):
28
+ """Output cannot be published safely; nothing was written."""
29
+
30
+
31
+ def validate_artifact_paths(paths: Iterable[str]) -> None:
32
+ """Check that relative artifact paths stay inside one new directory tree.
33
+
34
+ Each path must be a normalized relative POSIX path. No two paths may name the
35
+ same file on a case-insensitive file system, and no path may be a parent
36
+ directory of another.
37
+
38
+ Raises:
39
+ OutputError: For the first invalid path or collision.
40
+ """
41
+ seen: dict[str, str] = {}
42
+ directories: dict[str, str] = {}
43
+ for path in paths:
44
+ pure = PurePosixPath(path)
45
+ if (
46
+ not path
47
+ or "\\" in path
48
+ or pure.is_absolute()
49
+ or ":" in pure.parts[0]
50
+ or any(part in {"", ".", ".."} for part in path.split("/"))
51
+ ):
52
+ raise OutputError(f"artifact path {path!r} is not a safe relative path")
53
+ key = path.casefold()
54
+ if key in seen:
55
+ raise OutputError(f"artifact paths {seen[key]!r} and {path!r} collide")
56
+ seen[key] = path
57
+ for parent in pure.parents:
58
+ if str(parent) != ".":
59
+ directories.setdefault(str(parent).casefold(), path)
60
+ for key, path in seen.items():
61
+ if key in directories:
62
+ raise OutputError(
63
+ f"artifact path {path!r} is also a directory of {directories[key]!r}"
64
+ )
65
+
66
+
67
+ def check_destination(directory: Path) -> None:
68
+ """Require an absent directory, or an empty real directory.
69
+
70
+ Raises:
71
+ OutputError: When the destination is a symbolic link, a file or a
72
+ nonempty directory.
73
+ """
74
+ if directory.is_symlink():
75
+ raise OutputError(f"output directory {directory} is a symbolic link")
76
+ if directory.exists():
77
+ if not directory.is_dir():
78
+ raise OutputError(f"output path {directory} is not a directory")
79
+ if any(directory.iterdir()):
80
+ raise OutputError(f"output directory {directory} is not empty")
81
+
82
+
83
+ def publish_tree(directory: Path, files: Sequence[tuple[str, str]]) -> None:
84
+ """Write ``files`` to a staging directory, then move it to ``directory``.
85
+
86
+ Args:
87
+ directory: The absent or empty destination.
88
+ files: Relative POSIX paths and their text, already validated.
89
+
90
+ Raises:
91
+ OutputError: When the destination is unsuitable.
92
+ OSError: When writing or moving fails; the staging tree is removed and
93
+ the destination is unchanged.
94
+ """
95
+ check_destination(directory)
96
+ parent = directory.parent
97
+ parent.mkdir(parents=True, exist_ok=True)
98
+ # mkdtemp creates 0700; publish with the existing directory's mode, or the
99
+ # mode an ordinary mkdir would give under the user's umask.
100
+ mode = directory.stat().st_mode & 0o7777 if directory.exists() else 0o777 & ~umask()
101
+ staging = Path(tempfile.mkdtemp(prefix=f".{directory.name}.", dir=parent))
102
+ try:
103
+ staging.chmod(mode)
104
+ for relative, text in files:
105
+ target = staging.joinpath(*relative.split("/"))
106
+ target.parent.mkdir(parents=True, exist_ok=True)
107
+ target.write_text(text, encoding="utf-8")
108
+ check_destination(directory)
109
+ if directory.exists():
110
+ directory.rmdir()
111
+ os.replace(staging, directory)
112
+ except BaseException:
113
+ shutil.rmtree(staging, ignore_errors=True)
114
+ raise
@@ -2,7 +2,9 @@
2
2
 
3
3
  Every IR operation goes through the `morphir` library: the shared format-version
4
4
  reader, the v3 (3.0.x and 3.1.x) and draft v4 codecs, and `morphir.ir.migrate`.
5
- The CLI adds file handling and reporting only.
5
+ ``compile`` and ``generate`` go through the public `morphir_tools.python` API,
6
+ which loads the native compiler only when one of them runs. The CLI adds file
7
+ handling and reporting only.
6
8
 
7
9
  Exit codes: 0 for success (including success with warnings), 1 when the library
8
10
  reports a diagnostic, 2 for a usage error.
@@ -14,7 +16,7 @@ import os
14
16
  import tempfile
15
17
  from enum import Enum
16
18
  from pathlib import Path
17
- from typing import Annotated
19
+ from typing import TYPE_CHECKING, Annotated
18
20
 
19
21
  import typer
20
22
 
@@ -30,6 +32,13 @@ from morphir.ir.v4 import distribution as v4_dist
30
32
  from morphir.ir.v4.name import Name as Name4
31
33
  from morphir.ir.v4.path import Path as Path4
32
34
 
35
+ from ._output import OutputError, publish_tree, umask, validate_artifact_paths
36
+
37
+ if TYPE_CHECKING:
38
+ from .python import CompilerDiagnostic
39
+
40
+ __all__ = ["OutputError", "app", "main", "validate_artifact_paths"]
41
+
33
42
  app = typer.Typer(
34
43
  name="morphir-python",
35
44
  help="Command-line tools for Morphir IR.",
@@ -44,6 +53,13 @@ ir_app = typer.Typer(
44
53
  app.add_typer(ir_app, name="ir")
45
54
 
46
55
 
56
+ class V4Spelling(str, Enum):
57
+ """The draft v4 JSON spelling to write."""
58
+
59
+ COMPACT = "compact"
60
+ EXPANDED = "expanded"
61
+
62
+
47
63
  class Target(str, Enum):
48
64
  """The IR version to convert to."""
49
65
 
@@ -63,6 +79,9 @@ def _read(path: Path) -> str:
63
79
  except OSError as error:
64
80
  typer.echo(f"error: cannot read {path}: {error.strerror}", err=True)
65
81
  raise typer.Exit(1) from None
82
+ except UnicodeDecodeError:
83
+ typer.echo(f"error: cannot read {path}: the file is not UTF-8", err=True)
84
+ raise typer.Exit(1) from None
66
85
 
67
86
 
68
87
  def _write_atomically(target: Path, text: str) -> None:
@@ -71,6 +90,9 @@ def _write_atomically(target: Path, text: str) -> None:
71
90
  prefix=f".{target.name}.", suffix=".tmp", dir=directory
72
91
  )
73
92
  try:
93
+ # mkstemp creates 0600; keep an existing file's mode, or use the umask.
94
+ mode = target.stat().st_mode & 0o7777 if target.exists() else 0o666 & ~umask()
95
+ os.chmod(fd, mode)
74
96
  with os.fdopen(fd, "w", encoding="utf-8") as handle:
75
97
  handle.write(text)
76
98
  Path(temp_name).replace(target)
@@ -169,6 +191,14 @@ def convert(
169
191
  "writing v4, which spells an input as a bare type."
170
192
  ),
171
193
  ] = False,
194
+ v4_spelling: Annotated[
195
+ V4Spelling,
196
+ typer.Option(
197
+ "--v4-spelling",
198
+ help="The v4 JSON spelling to write: compact, the canonical form, or "
199
+ "expanded. Ignored when writing v3.",
200
+ ),
201
+ ] = V4Spelling.COMPACT,
172
202
  ) -> None:
173
203
  """Convert between format 3 (3.0.x and 3.1.x) and draft IR v4.
174
204
 
@@ -194,7 +224,9 @@ def convert(
194
224
  upgraded = Ok(migrated.value, migrated.warnings)
195
225
  if drop_input_attributes:
196
226
  upgraded = drop_inputs(upgraded.value)
197
- document = v4_codec.encode_versioned_distribution(upgraded.value)
227
+ document = v4_codec.encode_versioned_distribution(
228
+ upgraded.value, spelling=v4_codec.Spelling(v4_spelling.value)
229
+ )
198
230
  warnings: tuple[Diagnostic, ...] = upgraded.warnings
199
231
  else:
200
232
  if not _is_v4(version):
@@ -218,6 +250,165 @@ def convert(
218
250
  typer.echo(f"wrote {output} (format {to.value})")
219
251
 
220
252
 
253
+ def _report_compiler(diagnostics: tuple[CompilerDiagnostic, ...]) -> None:
254
+ for diagnostic in diagnostics:
255
+ typer.echo(f"{diagnostic.severity.value}: {diagnostic.render()}", err=True)
256
+
257
+
258
+ def _fail(message: str) -> typer.Exit:
259
+ typer.echo(f"error: {message}", err=True)
260
+ return typer.Exit(1)
261
+
262
+
263
+ def _discover(source: Path) -> tuple[Path, list[Path]]:
264
+ """Return the source root and its Python files, sorted, without following links."""
265
+ if source.is_dir() and not source.is_symlink():
266
+ files: list[Path] = []
267
+ for directory, names, filenames in os.walk(source, followlinks=False):
268
+ names[:] = sorted(
269
+ name
270
+ for name in names
271
+ if not name.startswith(".") and name != "__pycache__"
272
+ )
273
+ files.extend(
274
+ Path(directory) / name
275
+ for name in sorted(filenames)
276
+ if name.endswith(".py") and not (Path(directory) / name).is_symlink()
277
+ )
278
+ if not files:
279
+ raise _fail(f"no Python source files under {source}")
280
+ return source, files
281
+ return source.parent, [source]
282
+
283
+
284
+ @app.command("compile")
285
+ def compile_command(
286
+ source: Annotated[
287
+ Path, typer.Argument(help="A Python file, or a directory of Python files.")
288
+ ],
289
+ package: Annotated[
290
+ str, typer.Option(help="Morphir package name, such as acme/example.")
291
+ ],
292
+ output: Annotated[
293
+ Path, typer.Option("--output", "-o", help="Where to write the IR document.")
294
+ ],
295
+ ir_version: Annotated[
296
+ Target, typer.Option("--ir-version", help="The IR version to write.")
297
+ ] = Target.V3,
298
+ expose: Annotated[
299
+ list[str] | None,
300
+ typer.Option(
301
+ "--expose",
302
+ help="Module to make public, such as acme/models. Repeat for more. "
303
+ "Without it every module is public.",
304
+ ),
305
+ ] = None,
306
+ ) -> None:
307
+ """Compile supported Python source to a versioned Morphir IR Library.
308
+
309
+ A directory is compiled as one package; a single file uses its parent
310
+ directory as the source root. Sources are parsed, never imported or run.
311
+ The output is written to a temporary file and renamed only on success, so a
312
+ failed compilation leaves an existing output file unchanged.
313
+ """
314
+ from .python import ( # Loads the compiler on demand.
315
+ CompiledV3,
316
+ CompileFailure,
317
+ IrVersion,
318
+ NativeCompilerError,
319
+ NativeCompilerUnavailable,
320
+ SourceDocument,
321
+ compile_sources,
322
+ )
323
+
324
+ root, files = _discover(source)
325
+ documents = [
326
+ SourceDocument(path.relative_to(root).as_posix(), _read(path)) for path in files
327
+ ]
328
+ version = IrVersion.V3 if ir_version is Target.V3 else IrVersion.V4
329
+ try:
330
+ result = compile_sources(
331
+ documents,
332
+ package=package,
333
+ exposed_modules=expose,
334
+ ir_version=version,
335
+ )
336
+ except (ValueError, NativeCompilerUnavailable) as error:
337
+ raise _fail(str(error)) from None
338
+ except NativeCompilerError as error:
339
+ raise _fail(f"internal compiler error: {error}") from None
340
+ if isinstance(result, CompileFailure):
341
+ _report_compiler(result.diagnostics)
342
+ raise typer.Exit(1)
343
+ if isinstance(result, CompiledV3):
344
+ document = v3_codec.encode_versioned_distribution(result.distribution)
345
+ else:
346
+ document = v4_codec.encode_versioned_distribution(result.distribution)
347
+ try:
348
+ _write_atomically(output, fv.dump_json(document))
349
+ except OSError as error:
350
+ raise _fail(f"cannot write {output}: {error.strerror}") from None
351
+ _report_compiler(result.warnings)
352
+ typer.echo(f"wrote {output} (format {ir_version.value})")
353
+ if ir_version is Target.V4:
354
+ typer.echo(f"note: {v4_codec.DRAFT_NOTICE}")
355
+
356
+
357
+ @app.command("generate")
358
+ def generate_command(
359
+ file: Annotated[Path, typer.Argument(help="A versioned morphir-ir.json file.")],
360
+ output_dir: Annotated[
361
+ Path,
362
+ typer.Option(
363
+ "--output-dir", help="A new or empty directory for the Python modules."
364
+ ),
365
+ ],
366
+ ) -> None:
367
+ """Generate Python modules from a format 3.0 or draft v4 Library.
368
+
369
+ Module paths are preserved under the output directory. All files are written
370
+ to a staging directory first and published together, so a failure leaves no
371
+ partial output. Legacy formats 1 and 2 must be upgraded with ir convert first.
372
+ """
373
+ from .python import ( # Loads the compiler on demand.
374
+ GenerationFailure,
375
+ NativeCompilerError,
376
+ NativeCompilerUnavailable,
377
+ generate_python,
378
+ )
379
+
380
+ text = _read(file)
381
+ try:
382
+ version, _ = fv.read_document(text)
383
+ model: object = (
384
+ v4_codec.decode_versioned_distribution(text)
385
+ if _is_v4(version)
386
+ else v3_codec.decode_versioned_distribution(text)
387
+ )
388
+ except DiagnosticError as error:
389
+ _report(error.diagnostics)
390
+ raise typer.Exit(1) from None
391
+ try:
392
+ result = generate_python(model)
393
+ except NativeCompilerUnavailable as error:
394
+ raise _fail(str(error)) from None
395
+ except NativeCompilerError as error:
396
+ raise _fail(f"internal compiler error: {error}") from None
397
+ if isinstance(result, GenerationFailure):
398
+ _report_compiler(result.diagnostics)
399
+ raise typer.Exit(1)
400
+ files = [(artifact.path, artifact.text) for artifact in result.artifacts]
401
+ try:
402
+ validate_artifact_paths(path for path, _ in files)
403
+ publish_tree(output_dir, files)
404
+ except OutputError as error:
405
+ raise _fail(str(error)) from None
406
+ except OSError as error:
407
+ raise _fail(f"cannot write {output_dir}: {error.strerror}") from None
408
+ _report_compiler(result.warnings)
409
+ typer.echo(f"wrote {len(files)} modules to {output_dir}")
410
+
411
+
221
412
  def main() -> None:
222
413
  """Run the ``morphir-python`` command line."""
223
414
  app()
@@ -0,0 +1,71 @@
1
+ r"""Compile Python to Morphir IR and generate Python from Morphir IR.
2
+
3
+ This is the typed, in-process API behind ``morphir-python compile`` and
4
+ ``morphir-python generate``. A native compiler does the work; callers need
5
+ neither Rust nor a separate Morphir executable, and the native compiler loads
6
+ only when one of these functions is first called.
7
+
8
+ Example:
9
+ >>> from morphir_tools.python import SourceDocument, compile_sources
10
+ >>> from morphir_tools.python import CompiledV3, generate_python
11
+ >>> source = SourceDocument(
12
+ ... "rules.py",
13
+ ... "def choose(flag: bool, a: int, b: int) -> int:\n"
14
+ ... " return a if flag else b\n",
15
+ ... )
16
+ >>> compiled = compile_sources([source], package="acme/example")
17
+ >>> isinstance(compiled, CompiledV3)
18
+ True
19
+ >>> generated = generate_python(compiled.distribution)
20
+ >>> [artifact.path for artifact in generated.artifacts]
21
+ ['rules.py']
22
+ """
23
+
24
+ from ._api import compile_sources, generate_python, native_compiler
25
+ from ._bridge import EXPECTED_BRIDGE_API, EXPECTED_NATIVE_VERSION
26
+ from ._types import (
27
+ CompiledV3,
28
+ CompiledV4,
29
+ CompileFailure,
30
+ CompilerDiagnostic,
31
+ CompileResult,
32
+ Generated,
33
+ GeneratedArtifact,
34
+ GenerateResult,
35
+ GenerationFailure,
36
+ IrVersion,
37
+ LibraryModel,
38
+ NativeCompilerError,
39
+ NativeCompilerInfo,
40
+ NativeCompilerUnavailable,
41
+ Severity,
42
+ SourceDocument,
43
+ SourceLocation,
44
+ SourcePosition,
45
+ )
46
+
47
+ __all__ = [
48
+ "EXPECTED_BRIDGE_API",
49
+ "EXPECTED_NATIVE_VERSION",
50
+ "CompileFailure",
51
+ "CompileResult",
52
+ "CompiledV3",
53
+ "CompiledV4",
54
+ "CompilerDiagnostic",
55
+ "GenerateResult",
56
+ "Generated",
57
+ "GeneratedArtifact",
58
+ "GenerationFailure",
59
+ "IrVersion",
60
+ "LibraryModel",
61
+ "NativeCompilerError",
62
+ "NativeCompilerInfo",
63
+ "NativeCompilerUnavailable",
64
+ "Severity",
65
+ "SourceDocument",
66
+ "SourceLocation",
67
+ "SourcePosition",
68
+ "compile_sources",
69
+ "generate_python",
70
+ "native_compiler",
71
+ ]
@@ -0,0 +1,215 @@
1
+ """Compile Python sources to Morphir IR and generate Python from IR."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, cast
7
+
8
+ from morphir.ir import format_version as fv
9
+ from morphir.ir.diagnostics import DiagnosticError
10
+ from morphir.ir.v3 import codec as v3_codec
11
+ from morphir.ir.v3 import distribution as v3_dist
12
+ from morphir.ir.v4 import codec as v4_codec
13
+ from morphir.ir.v4 import distribution as v4_dist
14
+
15
+ from . import _bridge
16
+ from . import diagnostics as codes
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Callable, Iterable, Mapping, Sequence
20
+
21
+ from ._types import (
22
+ CompiledV3,
23
+ CompiledV4,
24
+ CompileFailure,
25
+ CompilerDiagnostic,
26
+ CompileResult,
27
+ Generated,
28
+ GeneratedArtifact,
29
+ GenerateResult,
30
+ GenerationFailure,
31
+ IrVersion,
32
+ LibraryModel,
33
+ NativeCompilerError,
34
+ NativeCompilerInfo,
35
+ Severity,
36
+ SourceDocument,
37
+ SourceLocation,
38
+ SourcePosition,
39
+ )
40
+
41
+
42
+ def native_compiler() -> NativeCompilerInfo:
43
+ """Load the native compiler and report its identity.
44
+
45
+ Raises:
46
+ NativeCompilerUnavailable: When it is missing or incompatible.
47
+ """
48
+ return _bridge.load()
49
+
50
+
51
+ def compile_sources(
52
+ sources: Iterable[SourceDocument],
53
+ *,
54
+ package: str,
55
+ exposed_modules: Sequence[str] | None = None,
56
+ ir_version: IrVersion = IrVersion.V3,
57
+ ) -> CompileResult:
58
+ """Compile Python source documents to a Morphir IR Library.
59
+
60
+ The sources are parsed statically: nothing is imported or executed. The
61
+ order of ``sources`` does not affect the result.
62
+
63
+ Args:
64
+ sources: The documents of one package, with paths relative to its root.
65
+ package: The Morphir package name, such as ``"acme/example"``.
66
+ exposed_modules: Module paths to make public, such as
67
+ ``("acme/models",)``. ``None`` exposes every module; an empty
68
+ sequence exposes none.
69
+ ir_version: The IR version to write. Defaults to stable v3.
70
+
71
+ Returns:
72
+ A `CompiledV3` or `CompiledV4` holding the decoded Library, or a
73
+ `CompileFailure` with at least one diagnostic and no model.
74
+
75
+ Raises:
76
+ ValueError: When ``package`` is empty or two documents share a path.
77
+ NativeCompilerUnavailable: When the native compiler is missing or
78
+ incompatible.
79
+ NativeCompilerError: When the native compiler fails internally.
80
+ """
81
+ documents = sorted(sources, key=lambda document: document.path)
82
+ if not package:
83
+ raise ValueError("package must name a Morphir package")
84
+ paths = [document.path for document in documents]
85
+ if len(set(paths)) != len(paths):
86
+ raise ValueError("source documents contain a duplicate path")
87
+ request = {
88
+ "package": package,
89
+ "sources": [{"path": d.path, "text": d.text} for d in documents],
90
+ "exposedModules": None if exposed_modules is None else list(exposed_modules),
91
+ "irVersion": ir_version.value,
92
+ }
93
+ output = _call(_bridge.module().compile, json.dumps(request))
94
+ diagnostics = _diagnostics(output)
95
+ ir = output.get("ir")
96
+ if not output.get("success") or not isinstance(ir, str):
97
+ return CompileFailure(diagnostics or (_missing_diagnostic(),))
98
+ try:
99
+ if ir_version is IrVersion.V3:
100
+ classic = v3_codec.decode_versioned_distribution(ir)
101
+ if isinstance(classic, v3_dist.Library):
102
+ return CompiledV3(classic, diagnostics)
103
+ else:
104
+ draft = v4_codec.decode_versioned_distribution(ir)
105
+ if isinstance(draft, v4_dist.LibraryDistribution):
106
+ return CompiledV4(draft, diagnostics)
107
+ except DiagnosticError as error:
108
+ return CompileFailure(_ir_diagnostics(error))
109
+ raise NativeCompilerError("the native compiler returned a non-Library model")
110
+
111
+
112
+ def generate_python(distribution: LibraryModel | object) -> GenerateResult:
113
+ """Generate Python modules from a v3 or draft v4 Library model.
114
+
115
+ Args:
116
+ distribution: A `morphir.ir.v3.distribution.Library` or
117
+ `morphir.ir.v4.distribution.LibraryDistribution`. Specs and
118
+ Application distributions are rejected with a diagnostic.
119
+
120
+ Returns:
121
+ `Generated` with one artifact per module, in path order, or a
122
+ `GenerationFailure` with at least one diagnostic and no artifacts.
123
+
124
+ Raises:
125
+ NativeCompilerUnavailable: When the native compiler is missing or
126
+ incompatible.
127
+ NativeCompilerError: When the native compiler fails internally.
128
+ """
129
+ try:
130
+ match distribution:
131
+ case v3_dist.Library():
132
+ document = v3_codec.encode_versioned_distribution(distribution)
133
+ case v4_dist.LibraryDistribution():
134
+ document = v4_codec.encode_versioned_distribution(distribution)
135
+ case _:
136
+ kind = type(distribution).__name__
137
+ return GenerationFailure(
138
+ (
139
+ CompilerDiagnostic(
140
+ codes.UNSUPPORTED_DISTRIBUTION,
141
+ Severity.ERROR,
142
+ "Python generation needs a Library distribution, "
143
+ f"not {kind}",
144
+ ),
145
+ )
146
+ )
147
+ except DiagnosticError as error:
148
+ return GenerationFailure(_ir_diagnostics(error))
149
+ output = _call(_bridge.module().generate, fv.dump_json(document))
150
+ diagnostics = _diagnostics(output)
151
+ raw = output.get("artifacts")
152
+ if not output.get("success") or not isinstance(raw, list):
153
+ return GenerationFailure(diagnostics or (_missing_diagnostic(),))
154
+ artifacts = tuple(
155
+ sorted(
156
+ (
157
+ GeneratedArtifact(str(item["path"]), str(item["content"]))
158
+ for item in cast("list[Mapping[str, object]]", raw)
159
+ ),
160
+ key=lambda artifact: artifact.path,
161
+ )
162
+ )
163
+ return Generated(artifacts, diagnostics)
164
+
165
+
166
+ def _call(function: Callable[[str], str], argument: str) -> Mapping[str, object]:
167
+ output = cast("Mapping[str, object]", json.loads(function(argument)))
168
+ internal = output.get("internalError")
169
+ if internal is not None:
170
+ raise NativeCompilerError(str(internal))
171
+ return output
172
+
173
+
174
+ def _missing_diagnostic() -> CompilerDiagnostic:
175
+ return CompilerDiagnostic(
176
+ codes.PY_SERIALIZATION,
177
+ Severity.ERROR,
178
+ "the native compiler failed without a diagnostic",
179
+ )
180
+
181
+
182
+ def _diagnostics(output: Mapping[str, object]) -> tuple[CompilerDiagnostic, ...]:
183
+ items = cast("list[Mapping[str, object]]", output.get("diagnostics") or [])
184
+ return tuple(_diagnostic(item) for item in items)
185
+
186
+
187
+ def _diagnostic(item: Mapping[str, object]) -> CompilerDiagnostic:
188
+ location = cast("Mapping[str, object] | None", item.get("location"))
189
+ source = None
190
+ if location is not None:
191
+ start = cast("Mapping[str, int]", location["start"])
192
+ end = cast("Mapping[str, int]", location["end"])
193
+ source = SourceLocation(
194
+ str(location["path"]),
195
+ SourcePosition(start["line"], start["character"]),
196
+ SourcePosition(end["line"], end["character"]),
197
+ )
198
+ return CompilerDiagnostic(
199
+ code=str(item.get("code") or codes.PY_SERIALIZATION),
200
+ severity=Severity(str(item["severity"])),
201
+ message=str(item["message"]),
202
+ source=source,
203
+ )
204
+
205
+
206
+ def _ir_diagnostics(error: DiagnosticError) -> tuple[CompilerDiagnostic, ...]:
207
+ return tuple(
208
+ CompilerDiagnostic(
209
+ code=item.code,
210
+ severity=Severity.ERROR,
211
+ message=item.message,
212
+ ir_path=item.location,
213
+ )
214
+ for item in error.diagnostics
215
+ )
@@ -0,0 +1,84 @@
1
+ """Lazy loading and compatibility checks for the private native compiler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from importlib import import_module, metadata
7
+ from typing import Protocol, cast
8
+
9
+ from ._types import NativeCompilerInfo, NativeCompilerUnavailable
10
+
11
+ EXPECTED_NATIVE_VERSION = "0.1.1"
12
+ """The exact ``morphir-python-native`` version this release was qualified with.
13
+
14
+ It must equal the pin in this distribution's ``pyproject.toml``.
15
+ """
16
+
17
+ EXPECTED_BRIDGE_API = 1
18
+ """The bridge contract revision this release speaks."""
19
+
20
+ _DISTRIBUTION = "morphir-python-native"
21
+
22
+
23
+ class NativeModule(Protocol):
24
+ """The private ``morphir_python_native._native`` module."""
25
+
26
+ BRIDGE_API: int
27
+ COMPILER_VERSION: str
28
+ __version__: str
29
+
30
+ def compile(self, request: str) -> str:
31
+ """Compile a JSON request."""
32
+ ...
33
+
34
+ def generate(self, ir: str) -> str:
35
+ """Generate Python from IR JSON text."""
36
+ ...
37
+
38
+
39
+ def _unavailable(detail: str) -> NativeCompilerUnavailable:
40
+ return NativeCompilerUnavailable(
41
+ f"the Morphir Python compiler is unavailable: {detail}. Reinstall "
42
+ f"morphir-python, which requires {_DISTRIBUTION}=={EXPECTED_NATIVE_VERSION}."
43
+ )
44
+
45
+
46
+ @functools.cache
47
+ def load() -> NativeCompilerInfo:
48
+ """Import the native module once and check that this release can use it.
49
+
50
+ Raises:
51
+ NativeCompilerUnavailable: When the module is missing, or its
52
+ distribution version, module version or bridge revision differ from
53
+ what this release expects.
54
+ """
55
+ try:
56
+ installed = metadata.version(_DISTRIBUTION)
57
+ except metadata.PackageNotFoundError:
58
+ raise _unavailable(f"{_DISTRIBUTION} is not installed") from None
59
+ try:
60
+ native = cast("NativeModule", import_module("morphir_python_native._native"))
61
+ except ImportError as error:
62
+ raise _unavailable(f"its native module cannot be imported ({error})") from None
63
+ if installed != EXPECTED_NATIVE_VERSION:
64
+ raise _unavailable(
65
+ f"{_DISTRIBUTION} {installed} is installed but this release needs "
66
+ f"{EXPECTED_NATIVE_VERSION}"
67
+ )
68
+ if native.__version__ != installed:
69
+ raise _unavailable(
70
+ f"the native module reports {native.__version__} but its distribution "
71
+ f"is {installed}"
72
+ )
73
+ if native.BRIDGE_API != EXPECTED_BRIDGE_API:
74
+ raise _unavailable(
75
+ f"bridge revision {native.BRIDGE_API} differs from the expected "
76
+ f"{EXPECTED_BRIDGE_API}"
77
+ )
78
+ return NativeCompilerInfo(installed, native.BRIDGE_API, native.COMPILER_VERSION)
79
+
80
+
81
+ def module() -> NativeModule:
82
+ """Return the checked native module, loading it on first use."""
83
+ load()
84
+ return cast("NativeModule", import_module("morphir_python_native._native"))
@@ -0,0 +1,201 @@
1
+ """Immutable inputs and results of the Python compiler API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+ from morphir.ir.v3 import distribution as v3_dist
9
+ from morphir.ir.v4 import distribution as v4_dist
10
+
11
+
12
+ class IrVersion(Enum):
13
+ """The Morphir IR version a compilation writes."""
14
+
15
+ V3 = "3"
16
+ """Stable format 3.0.0."""
17
+ V4 = "4"
18
+ """Draft format 4.0.0."""
19
+
20
+
21
+ class Severity(Enum):
22
+ """How serious a compiler diagnostic is."""
23
+
24
+ ERROR = "error"
25
+ WARNING = "warning"
26
+ INFO = "info"
27
+ HINT = "hint"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class SourceDocument:
32
+ """One Python source file to compile.
33
+
34
+ Attributes:
35
+ path: Path relative to the source root, with ``/`` separators, such as
36
+ ``"acme/rules.py"``. It decides the Morphir module name.
37
+ text: The complete source text. It is parsed, never imported or run.
38
+ """
39
+
40
+ path: str
41
+ text: str
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class SourcePosition:
46
+ """A zero-based line and UTF-16 character offset in a source document."""
47
+
48
+ line: int
49
+ character: int
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class SourceLocation:
54
+ """A half-open range in one source document."""
55
+
56
+ path: str
57
+ start: SourcePosition
58
+ end: SourcePosition
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CompilerDiagnostic:
63
+ """A problem reported by compilation or generation.
64
+
65
+ Attributes:
66
+ code: Stable code from `morphir_tools.python.diagnostics` or
67
+ `morphir.ir.diagnostics`.
68
+ severity: How serious the problem is.
69
+ message: Human-readable explanation. Callers must not parse it.
70
+ source: The source range, when the problem is in a source document.
71
+ ir_path: Path from the IR document root, when the problem is in IR.
72
+ """
73
+
74
+ code: str
75
+ severity: Severity
76
+ message: str
77
+ source: SourceLocation | None = None
78
+ ir_path: tuple[str | int, ...] = ()
79
+
80
+ def render(self) -> str:
81
+ """Render as ``code at location: message``, like IR diagnostics.
82
+
83
+ Source locations are written ``path:line:column`` with one-based line and
84
+ column numbers.
85
+ """
86
+ if self.source is not None:
87
+ start = self.source.start
88
+ where = f"{self.source.path}:{start.line + 1}:{start.character + 1}"
89
+ elif self.ir_path:
90
+ where = "/".join(str(part) for part in self.ir_path)
91
+ else:
92
+ where = "<request>"
93
+ return f"{self.code} at {where}: {self.message}"
94
+
95
+
96
+ def _warnings_only(diagnostics: tuple[CompilerDiagnostic, ...]) -> None:
97
+ if any(item.severity is Severity.ERROR for item in diagnostics):
98
+ raise ValueError("a successful result cannot carry error diagnostics")
99
+
100
+
101
+ def _not_empty(diagnostics: tuple[CompilerDiagnostic, ...]) -> None:
102
+ if not diagnostics:
103
+ raise ValueError("a failure needs at least one diagnostic")
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class CompiledV3:
108
+ """A successful compilation to a format 3.0.0 Library."""
109
+
110
+ distribution: v3_dist.Library
111
+ warnings: tuple[CompilerDiagnostic, ...] = ()
112
+
113
+ def __post_init__(self) -> None:
114
+ _warnings_only(self.warnings)
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class CompiledV4:
119
+ """A successful compilation to a draft format 4.0.0 Library."""
120
+
121
+ distribution: v4_dist.LibraryDistribution
122
+ warnings: tuple[CompilerDiagnostic, ...] = ()
123
+
124
+ def __post_init__(self) -> None:
125
+ _warnings_only(self.warnings)
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class CompileFailure:
130
+ """A compilation that produced no model."""
131
+
132
+ diagnostics: tuple[CompilerDiagnostic, ...]
133
+
134
+ def __post_init__(self) -> None:
135
+ _not_empty(self.diagnostics)
136
+
137
+
138
+ type CompileResult = CompiledV3 | CompiledV4 | CompileFailure
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class GeneratedArtifact:
143
+ """One generated Python module.
144
+
145
+ Attributes:
146
+ path: Path relative to the output root, with ``/`` separators.
147
+ text: The module source.
148
+ """
149
+
150
+ path: str
151
+ text: str
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Generated:
156
+ """Successful generation: every module of the input Library."""
157
+
158
+ artifacts: tuple[GeneratedArtifact, ...]
159
+ warnings: tuple[CompilerDiagnostic, ...] = ()
160
+
161
+ def __post_init__(self) -> None:
162
+ _warnings_only(self.warnings)
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class GenerationFailure:
167
+ """A generation that produced no artifacts."""
168
+
169
+ diagnostics: tuple[CompilerDiagnostic, ...]
170
+
171
+ def __post_init__(self) -> None:
172
+ _not_empty(self.diagnostics)
173
+
174
+
175
+ type GenerateResult = Generated | GenerationFailure
176
+
177
+ type LibraryModel = v3_dist.Library | v4_dist.LibraryDistribution
178
+ """A Library model that generation accepts."""
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class NativeCompilerInfo:
183
+ """Identity of the loaded native compiler.
184
+
185
+ Attributes:
186
+ version: Version of the ``morphir-python-native`` distribution and module.
187
+ bridge_api: Revision of the private bridge contract.
188
+ compiler_version: Version of the linked ``morphir-python-binding`` crate.
189
+ """
190
+
191
+ version: str
192
+ bridge_api: int
193
+ compiler_version: str
194
+
195
+
196
+ class NativeCompilerUnavailable(ImportError): # noqa: N818 - reads as a state
197
+ """The native compiler is missing or incompatible with this release."""
198
+
199
+
200
+ class NativeCompilerError(RuntimeError):
201
+ """The native compiler failed internally; this is a bug, not a user error."""
@@ -0,0 +1,33 @@
1
+ """Diagnostic codes reported by the Python compiler API.
2
+
3
+ Every code the API can report lives in this module, as `morphir.ir.diagnostics`
4
+ does for the IR readers and codecs.
5
+
6
+ - ``PY001`` to ``PY006`` come from the native ``morphir-python-binding`` compiler.
7
+ They are the same codes its MEP extension reports, so Rust, WASM and Python
8
+ callers see one vocabulary.
9
+ - ``unsupported_distribution`` is local to this API: generation accepts only a
10
+ Library distribution.
11
+ - Decoding and encoding failures keep the `morphir.ir.diagnostics` code that the
12
+ codec reported, such as ``unknown_node`` or ``no_v4_spelling``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # Native compiler (morphir-python-binding).
18
+ PY_REQUEST = "PY001"
19
+ """Unsupported request or option."""
20
+ PY_SYNTAX = "PY002"
21
+ """Python syntax error, with a source range."""
22
+ PY_NAME = "PY003"
23
+ """Invalid name or naming collision."""
24
+ PY_UNSUPPORTED = "PY004"
25
+ """Unsupported declaration, annotation or IR feature."""
26
+ PY_SERIALIZATION = "PY005"
27
+ """IR decoding or serialization, or generated syntax failure."""
28
+ PY_PARSE_STAGE = "PY006"
29
+ """Parse-stage output was requested but is unavailable."""
30
+
31
+ # Python API (morphir-python).
32
+ UNSUPPORTED_DISTRIBUTION = "unsupported_distribution"
33
+ """Generation received a Specs or Application distribution."""
File without changes