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.
@@ -0,0 +1,2 @@
1
+ python/anabrid/redacc/configurations
2
+ *.so
@@ -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)