pyredacc 0.11.1__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.
- pyredacc-0.11.1/.gitignore +2 -0
- pyredacc-0.11.1/PKG-INFO +14 -0
- pyredacc-0.11.1/pyproject.toml +46 -0
- pyredacc-0.11.1/pyredacc/__init__.py +18 -0
- pyredacc-0.11.1/pyredacc/cli.py +164 -0
- pyredacc-0.11.1/pyredacc/compiler.py +189 -0
- pyredacc-0.11.1/pyredacc/executor.py +275 -0
- pyredacc-0.11.1/pyredacc/ode_adapter.py +171 -0
- pyredacc-0.11.1/pyredacc/registry.py +55 -0
- pyredacc-0.11.1/pyredacc/simulator.py +121 -0
- pyredacc-0.11.1/pyredacc/types.py +108 -0
- pyredacc-0.11.1/tests/__init__.py +0 -0
- pyredacc-0.11.1/tests/conftest.py +45 -0
- pyredacc-0.11.1/tests/fixtures/lucidac.apb +0 -0
- pyredacc-0.11.1/tests/fixtures/sample_config.json +1 -0
- pyredacc-0.11.1/tests/test_compiler.py +74 -0
- pyredacc-0.11.1/tests/test_e2e_compiler.py +61 -0
- pyredacc-0.11.1/tests/test_executor.py +348 -0
- pyredacc-0.11.1/tests/test_integration_compiler.py +101 -0
- pyredacc-0.11.1/tests/test_integration_simulator.py +88 -0
- pyredacc-0.11.1/tests/test_ode_adapter.py +214 -0
- pyredacc-0.11.1/tests/test_path_translation.py +57 -0
- pyredacc-0.11.1/tests/test_registry.py +100 -0
- pyredacc-0.11.1/uv.lock +1135 -0
pyredacc-0.11.1/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyredacc
|
|
3
|
+
Version: 0.11.1
|
|
4
|
+
Summary: Python frontend for the redacc analog compiler pipeline
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: docker>=7.0
|
|
7
|
+
Requires-Dist: matplotlib>=3.10.8
|
|
8
|
+
Requires-Dist: numpy>=2.4
|
|
9
|
+
Requires-Dist: protobuf>=5.0
|
|
10
|
+
Requires-Dist: pybrid-computing-native>=0.11.7
|
|
11
|
+
Requires-Dist: pybrid-computing>=0.11.7
|
|
12
|
+
Requires-Dist: sympy>=1.13
|
|
13
|
+
Requires-Dist: typer>=0.24.1
|
|
14
|
+
Requires-Dist: yaspin>=3.4.0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyredacc"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Python frontend for the redacc analog compiler pipeline"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"docker>=7.0",
|
|
8
|
+
"protobuf>=5.0",
|
|
9
|
+
"sympy>=1.13",
|
|
10
|
+
"numpy>=2.4",
|
|
11
|
+
"matplotlib>=3.10.8",
|
|
12
|
+
"pybrid-computing>=0.11.7",
|
|
13
|
+
"pybrid-computing-native>=0.11.7",
|
|
14
|
+
"typer>=0.24.1",
|
|
15
|
+
"yaspin>=3.4.0"
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=8.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling", "uv-dynamic-versioning"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
redacc = "pyredacc.cli:app"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["pyredacc"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.version]
|
|
34
|
+
source = "uv-dynamic-versioning"
|
|
35
|
+
|
|
36
|
+
[tool.uv-dynamic-versioning]
|
|
37
|
+
vcs = "git"
|
|
38
|
+
pattern = "^v(?P<base>\\d+\\.\\d+\\.\\d+)$"
|
|
39
|
+
format-jinja = "{% if distance == 0 %}{{ base }}{% else %}{{ base }}.dev{{ distance }}+{{ commit }}{% endif %}"
|
|
40
|
+
fallback-version = "0.1.0"
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
markers = [
|
|
45
|
+
"requires_docker: test needs a running Docker daemon",
|
|
46
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .types import Backend, CompilerResult, SympyODE
|
|
2
|
+
from .ode_adapter import ODEAdapter
|
|
3
|
+
from .compiler import Compiler
|
|
4
|
+
from .simulator import Simulator
|
|
5
|
+
from .executor import DockerExecutor, LocalExecutor
|
|
6
|
+
from .registry import DockerRegistry
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Backend",
|
|
10
|
+
"CompilerResult",
|
|
11
|
+
"ODEAdapter",
|
|
12
|
+
"Compiler",
|
|
13
|
+
"Simulator",
|
|
14
|
+
"DockerExecutor",
|
|
15
|
+
"LocalExecutor",
|
|
16
|
+
"DockerRegistry",
|
|
17
|
+
"SympyODE",
|
|
18
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple CLI forwarding all given arguments to Docker containers. Allows users
|
|
3
|
+
to run both compiler and simulator from the CLI, freeing them from the burden
|
|
4
|
+
of updating their installation.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from typing import Annotated
|
|
11
|
+
from yaspin import yaspin
|
|
12
|
+
from yaspin.spinners import Spinners
|
|
13
|
+
|
|
14
|
+
from .compiler import Backend
|
|
15
|
+
from .registry import DockerRegistry
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
help="Compile ODEs and run simulations for anabrid analog computers."
|
|
19
|
+
)
|
|
20
|
+
try:
|
|
21
|
+
DockerRegistry.from_env()
|
|
22
|
+
except:
|
|
23
|
+
print("Make sure to set CI_REGISTRY_USER and CI_REGISTRY_PASSWORD variables " +
|
|
24
|
+
"for access to the Docker registry.")
|
|
25
|
+
exit(1)
|
|
26
|
+
|
|
27
|
+
@app.command()
|
|
28
|
+
def compile(
|
|
29
|
+
input: Annotated[
|
|
30
|
+
str,
|
|
31
|
+
typer.Argument(help="Path to an .ana analang source file")
|
|
32
|
+
],
|
|
33
|
+
backend: Annotated[
|
|
34
|
+
Backend,
|
|
35
|
+
typer.Option(
|
|
36
|
+
case_sensitive=False,
|
|
37
|
+
help="LANE for LUCIDAC, NETWORK for REDAC"
|
|
38
|
+
)
|
|
39
|
+
],
|
|
40
|
+
device: Annotated[
|
|
41
|
+
str,
|
|
42
|
+
typer.Option(
|
|
43
|
+
help="Path to a device specification in .apb format"
|
|
44
|
+
)
|
|
45
|
+
],
|
|
46
|
+
output: Annotated[
|
|
47
|
+
str,
|
|
48
|
+
typer.Option(
|
|
49
|
+
help="Path to output artifact, format is .apb"
|
|
50
|
+
)
|
|
51
|
+
],
|
|
52
|
+
plugins_dir: Annotated[
|
|
53
|
+
str,
|
|
54
|
+
typer.Option(
|
|
55
|
+
help="Paths to directories containing plugins"
|
|
56
|
+
)
|
|
57
|
+
] = None,
|
|
58
|
+
pull: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option(
|
|
61
|
+
help="Whether to always pull the most recent compiler image before using"
|
|
62
|
+
)
|
|
63
|
+
] = True
|
|
64
|
+
):
|
|
65
|
+
"""Compile an ODE description to an analog hardware configuration."""
|
|
66
|
+
from .compiler import Compiler
|
|
67
|
+
|
|
68
|
+
compiler = Compiler(
|
|
69
|
+
backend=backend,
|
|
70
|
+
device=device,
|
|
71
|
+
plugins_dir=plugins_dir,
|
|
72
|
+
always_pull=pull
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with yaspin(Spinners.dots, text="Compiling...") as sp:
|
|
76
|
+
try:
|
|
77
|
+
result = compiler.run(input=input)
|
|
78
|
+
if result.exit_code == 0:
|
|
79
|
+
sp.ok("✔")
|
|
80
|
+
else:
|
|
81
|
+
sp.fail("✘")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
sp.fail("✘")
|
|
84
|
+
sys.stderr.write(f"{e}\n")
|
|
85
|
+
raise typer.Exit(code=1)
|
|
86
|
+
|
|
87
|
+
if result.stdout:
|
|
88
|
+
typer.echo(result.stdout.rstrip())
|
|
89
|
+
|
|
90
|
+
if result.exit_code != 0:
|
|
91
|
+
if result.stderr:
|
|
92
|
+
sys.stderr.write(result.stderr)
|
|
93
|
+
raise typer.Exit(code=result.exit_code)
|
|
94
|
+
|
|
95
|
+
with yaspin(Spinners.dots, text=f"Saving to {output}...") as sp:
|
|
96
|
+
try:
|
|
97
|
+
result.save(output)
|
|
98
|
+
sp.ok("✔")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
sp.fail("✘")
|
|
101
|
+
sys.stderr.write(f"{e}\n")
|
|
102
|
+
raise typer.Exit(code=1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def simulate(
|
|
107
|
+
port: Annotated[
|
|
108
|
+
int,
|
|
109
|
+
typer.Option(
|
|
110
|
+
help="Port to listen on"
|
|
111
|
+
)
|
|
112
|
+
] = 5732,
|
|
113
|
+
token: Annotated[
|
|
114
|
+
str,
|
|
115
|
+
typer.Option(
|
|
116
|
+
help="If set, requires authentication by the client"
|
|
117
|
+
)
|
|
118
|
+
] = None,
|
|
119
|
+
plugins_dir: Annotated[
|
|
120
|
+
str,
|
|
121
|
+
typer.Option(
|
|
122
|
+
help="Path to directory containing compiled plugins"
|
|
123
|
+
)
|
|
124
|
+
] = None,
|
|
125
|
+
pull: Annotated[
|
|
126
|
+
bool,
|
|
127
|
+
typer.Option(
|
|
128
|
+
help="Whether to always pull the most recent simulator image before using"
|
|
129
|
+
)
|
|
130
|
+
] = True
|
|
131
|
+
):
|
|
132
|
+
"""Run the simulator in the foreground until stopped with Ctrl+C."""
|
|
133
|
+
from .simulator import Simulator
|
|
134
|
+
|
|
135
|
+
sim = Simulator(
|
|
136
|
+
port=port,
|
|
137
|
+
plugins_dir=plugins_dir,
|
|
138
|
+
always_pull=pull
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
with yaspin(Spinners.dots, text="Starting simulator...") as sp:
|
|
142
|
+
try:
|
|
143
|
+
sim.start()
|
|
144
|
+
sp.ok("✔")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
sp.fail("✘")
|
|
147
|
+
sys.stderr.write(f"{e}\n")
|
|
148
|
+
raise typer.Exit(code=1)
|
|
149
|
+
|
|
150
|
+
typer.echo(f"Simulator listening on port {port}. Press Ctrl+C to stop.")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
while True:
|
|
154
|
+
time.sleep(1)
|
|
155
|
+
except KeyboardInterrupt:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
with yaspin(Spinners.dots, text="Stopping simulator...") as sp:
|
|
159
|
+
sim.stop()
|
|
160
|
+
sp.ok("✔")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
if __name__ == "__main__":
|
|
164
|
+
typer.run(app)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from typing import List, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .executor import DockerExecutor, LocalExecutor
|
|
8
|
+
from .ode_adapter import ODEAdapter
|
|
9
|
+
import pybrid.base.proto.main_pb2 as pb
|
|
10
|
+
from .types import Backend, CompilerResult, SympyODE
|
|
11
|
+
from pybrid.base.proto.io import ProtoIO
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Compiler:
|
|
17
|
+
"""Compile ODE systems to analog hardware configurations.
|
|
18
|
+
|
|
19
|
+
Accepts input as a file path, an analang text string, or a SympyODE
|
|
20
|
+
instance. Execution happens either inside a Docker container (default)
|
|
21
|
+
or via a local ``redacc`` binary when *executable* is provided.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
backend: Backend,
|
|
27
|
+
device: Optional[str] = None,
|
|
28
|
+
executable: Optional[str] = None,
|
|
29
|
+
image_name: str = "redacc-compiler",
|
|
30
|
+
image_tag: str = "latest",
|
|
31
|
+
plugins_dir: Optional[str] = None,
|
|
32
|
+
always_pull: bool = True,
|
|
33
|
+
):
|
|
34
|
+
self._backend = backend
|
|
35
|
+
self._device = device
|
|
36
|
+
|
|
37
|
+
if executable:
|
|
38
|
+
self._executor = LocalExecutor(executable=executable, plugins_dir=plugins_dir)
|
|
39
|
+
self._is_docker = False
|
|
40
|
+
else:
|
|
41
|
+
self._executor = DockerExecutor(
|
|
42
|
+
image_name=image_name,
|
|
43
|
+
image_tag=image_tag,
|
|
44
|
+
plugins_dir=plugins_dir,
|
|
45
|
+
always_pull=always_pull,
|
|
46
|
+
)
|
|
47
|
+
self._is_docker = True
|
|
48
|
+
|
|
49
|
+
def _build_args(
|
|
50
|
+
self,
|
|
51
|
+
input_path: str,
|
|
52
|
+
output_dir: str,
|
|
53
|
+
output_apb: bool = True,
|
|
54
|
+
container_output_dir: Optional[str] = None,
|
|
55
|
+
container_input_path: Optional[str] = None,
|
|
56
|
+
container_device_path: Optional[str] = None,
|
|
57
|
+
) -> List[str]:
|
|
58
|
+
args: List[str] = []
|
|
59
|
+
effective_output = container_output_dir or output_dir
|
|
60
|
+
effective_input = container_input_path or input_path
|
|
61
|
+
|
|
62
|
+
args.extend(["--backend", self._backend.name])
|
|
63
|
+
|
|
64
|
+
if self._device:
|
|
65
|
+
effective_device = container_device_path or self._device
|
|
66
|
+
args.extend(["--entity", effective_device])
|
|
67
|
+
|
|
68
|
+
if output_apb:
|
|
69
|
+
args.extend(["--output", os.path.join(effective_output, "config.apb")])
|
|
70
|
+
|
|
71
|
+
args.append(effective_input)
|
|
72
|
+
|
|
73
|
+
return args
|
|
74
|
+
|
|
75
|
+
def run(
|
|
76
|
+
self,
|
|
77
|
+
input: Union[str, SympyODE],
|
|
78
|
+
) -> CompilerResult:
|
|
79
|
+
"""Run the compiler on the given input.
|
|
80
|
+
|
|
81
|
+
*input* may be a file path (str), an analang text string, or a
|
|
82
|
+
``SympyODE`` instance. Strings that point to an existing file are
|
|
83
|
+
used directly; all other strings are treated as inline analang text.
|
|
84
|
+
"""
|
|
85
|
+
temp_input = None
|
|
86
|
+
work_dir = None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
if isinstance(input, dict):
|
|
90
|
+
raise TypeError(
|
|
91
|
+
"Dict input is no longer supported; "
|
|
92
|
+
"use a file path, analang text, or a SympyODE instance."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if isinstance(input, SympyODE):
|
|
96
|
+
adapter = ODEAdapter()
|
|
97
|
+
input = adapter.translate_sympy(input)
|
|
98
|
+
|
|
99
|
+
if isinstance(input, str) and os.path.isfile(input):
|
|
100
|
+
input_path = input
|
|
101
|
+
if self._is_docker:
|
|
102
|
+
work_dir = tempfile.mkdtemp()
|
|
103
|
+
os.chmod(work_dir, 0o755)
|
|
104
|
+
copied_input = os.path.join(
|
|
105
|
+
work_dir, os.path.basename(input_path)
|
|
106
|
+
)
|
|
107
|
+
shutil.copy2(input_path, copied_input)
|
|
108
|
+
os.chmod(copied_input, 0o644)
|
|
109
|
+
input_path = copied_input
|
|
110
|
+
else:
|
|
111
|
+
analang_text = input
|
|
112
|
+
if self._is_docker:
|
|
113
|
+
work_dir = tempfile.mkdtemp()
|
|
114
|
+
os.chmod(work_dir, 0o755)
|
|
115
|
+
import uuid
|
|
116
|
+
tmp_filename = f"input_{uuid.uuid4().hex[:8]}.ana"
|
|
117
|
+
tmp_path = os.path.join(work_dir, tmp_filename)
|
|
118
|
+
with open(tmp_path, "w") as f:
|
|
119
|
+
f.write(analang_text)
|
|
120
|
+
f.flush()
|
|
121
|
+
os.fsync(f.fileno())
|
|
122
|
+
os.chmod(tmp_path, 0o644)
|
|
123
|
+
input_path = tmp_path
|
|
124
|
+
temp_input = tmp_path
|
|
125
|
+
else:
|
|
126
|
+
tf = tempfile.NamedTemporaryFile(
|
|
127
|
+
mode="w", suffix=".ana", delete=False
|
|
128
|
+
)
|
|
129
|
+
tf.write(analang_text)
|
|
130
|
+
tf.close()
|
|
131
|
+
input_path = tf.name
|
|
132
|
+
temp_input = tf.name
|
|
133
|
+
|
|
134
|
+
container_output_dir = None
|
|
135
|
+
container_input_path = None
|
|
136
|
+
container_device_path = None
|
|
137
|
+
volumes = None
|
|
138
|
+
if self._is_docker:
|
|
139
|
+
# Mount work_dir read-write for both input and output
|
|
140
|
+
# This avoids issues with /tmp mounts in Docker
|
|
141
|
+
container_output_dir = "/work"
|
|
142
|
+
container_input_path = f"/work/{os.path.basename(input_path)}"
|
|
143
|
+
if work_dir is None:
|
|
144
|
+
work_dir = tempfile.mkdtemp()
|
|
145
|
+
volumes = {work_dir: "/work"}
|
|
146
|
+
if self._device:
|
|
147
|
+
device_dir = os.path.dirname(os.path.abspath(self._device))
|
|
148
|
+
container_device_path = f"/device/{os.path.basename(self._device)}"
|
|
149
|
+
volumes[device_dir] = "/device"
|
|
150
|
+
|
|
151
|
+
# For LocalExecutor, use output_dir directly (original behavior)
|
|
152
|
+
# For Docker, use work_dir as the output source
|
|
153
|
+
output_dir = work_dir or tempfile.mkdtemp()
|
|
154
|
+
|
|
155
|
+
args = self._build_args(
|
|
156
|
+
input_path=input_path,
|
|
157
|
+
output_dir=output_dir,
|
|
158
|
+
output_apb=True,
|
|
159
|
+
container_output_dir=container_output_dir,
|
|
160
|
+
container_input_path=container_input_path,
|
|
161
|
+
container_device_path=container_device_path,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
self._executor.ensure_available()
|
|
165
|
+
exit_code, stdout, stderr = self._executor.run(
|
|
166
|
+
args, input_file=None, output_dir=output_dir, volumes=volumes
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
module = None
|
|
170
|
+
|
|
171
|
+
apb_path = os.path.join(output_dir, "config.apb")
|
|
172
|
+
if os.path.isfile(apb_path):
|
|
173
|
+
with open(apb_path, "rb") as f:
|
|
174
|
+
file_msg = pb.File()
|
|
175
|
+
file_msg.ParseFromString(f.read())
|
|
176
|
+
if file_msg.HasField("module"):
|
|
177
|
+
module = file_msg.module
|
|
178
|
+
|
|
179
|
+
return CompilerResult(
|
|
180
|
+
exit_code=exit_code,
|
|
181
|
+
stdout=stdout,
|
|
182
|
+
stderr=stderr,
|
|
183
|
+
module=module,
|
|
184
|
+
)
|
|
185
|
+
finally:
|
|
186
|
+
if temp_input and os.path.isfile(temp_input):
|
|
187
|
+
os.unlink(temp_input)
|
|
188
|
+
if work_dir:
|
|
189
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|