Skip to content

Commit 575d2cc

Browse files
committed
Merge branch 'dev' of github.com:OpenCyphal/pycyphal into dev
2 parents c7b8223 + ef7d5fa commit 575d2cc

5 files changed

Lines changed: 126 additions & 63 deletions

File tree

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def test(session):
121121
"mypy ~= 1.15.0",
122122
"pylint == 3.3.7",
123123
)
124-
session.run("mypy", *map(str, src_dirs), str(compiled_dir))
124+
session.run("mypy", *map(str, src_dirs))
125125
session.run("pylint", *map(str, src_dirs), env={"PYTHONPATH": str(compiled_dir)})
126126

127127
# Publish coverage statistics. This also has to be run from the test session to access the coverage files.

pycyphal/dsdl/_compiler.py

Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import nunavut
1616
import nunavut.lang
1717
import nunavut.jinja
18-
18+
from pycyphal.dsdl._lockfile import Locker
1919

2020
_AnyPath = Union[str, pathlib.Path]
2121

@@ -171,26 +171,12 @@ def compile( # pylint: disable=redefined-builtin
171171
(root_namespace_name,) = set(map(lambda x: x.root_namespace, composite_types)) # type: ignore
172172
_logger.info("Read %d definitions from root namespace %r", len(composite_types), root_namespace_name)
173173

174-
# Generate code
175-
assert isinstance(output_directory, pathlib.Path)
176174
root_ns = nunavut.build_namespace_tree(
177175
types=composite_types,
178176
root_namespace_dir=str(root_namespace_directory),
179177
output_dir=str(output_directory),
180178
language_context=language_context,
181179
)
182-
code_generator = nunavut.jinja.DSDLCodeGenerator(
183-
namespace=root_ns,
184-
generate_namespace_types=nunavut.YesNoDefault.YES,
185-
followlinks=True,
186-
)
187-
code_generator.generate_all()
188-
_logger.info(
189-
"Generated %d types from the root namespace %r in %.1f seconds",
190-
len(composite_types),
191-
root_namespace_name,
192-
time.monotonic() - started_at,
193-
)
194180
else:
195181
root_ns = nunavut.build_namespace_tree(
196182
types=[],
@@ -199,28 +185,35 @@ def compile( # pylint: disable=redefined-builtin
199185
language_context=language_context,
200186
)
201187

202-
support_generator = nunavut.jinja.SupportGenerator(
203-
namespace=root_ns,
204-
)
205-
support_generator.generate_all()
206-
207-
# A minor UX improvement; see https://github.com/OpenCyphal/pycyphal/issues/115
208-
for p in sys.path:
209-
if pathlib.Path(p).resolve() == pathlib.Path(output_directory):
210-
break
211-
else:
212-
if os.name == "nt":
213-
quick_fix = f'Quick fix: `$env:PYTHONPATH += ";{output_directory.resolve()}"`'
214-
elif os.name == "posix":
215-
quick_fix = f'Quick fix: `export PYTHONPATH="{output_directory.resolve()}"`'
216-
else:
217-
quick_fix = "Quick fix is not available for this OS."
218-
_logger.info(
219-
"Generated package is stored in %r, which is not in Python module search path list. "
220-
"The package will fail to import unless you add the destination directory to sys.path or PYTHONPATH. %s",
221-
str(output_directory),
222-
quick_fix,
223-
)
188+
if root_namespace_name is not None:
189+
with Locker(
190+
root_namespace_name=root_namespace_name,
191+
output_directory=output_directory,
192+
) as lockfile:
193+
if lockfile:
194+
assert isinstance(output_directory, pathlib.Path)
195+
code_generator = nunavut.jinja.DSDLCodeGenerator(
196+
namespace=root_ns,
197+
generate_namespace_types=nunavut.YesNoDefault.YES,
198+
followlinks=True,
199+
)
200+
code_generator.generate_all()
201+
_logger.info(
202+
"Generated %d types from the root namespace %r in %.1f seconds",
203+
len(composite_types),
204+
root_namespace_name,
205+
time.monotonic() - started_at,
206+
)
207+
208+
with Locker(
209+
root_namespace_name="_support_",
210+
output_directory=output_directory,
211+
) as support_lockfile:
212+
if support_lockfile:
213+
support_generator = nunavut.jinja.SupportGenerator(
214+
namespace=root_ns,
215+
)
216+
support_generator.generate_all()
224217

225218
return GeneratedPackageInfo(
226219
path=pathlib.Path(output_directory) / pathlib.Path(root_namespace_name),

pycyphal/dsdl/_lockfile.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright (c) 2025 OpenCyphal
2+
# This software is distributed under the terms of the MIT License.
3+
# Author: Huong Pham <huong.pham@zubax.com>
4+
5+
import logging
6+
import pathlib
7+
import time
8+
from io import TextIOWrapper
9+
from pathlib import Path
10+
from types import TracebackType
11+
12+
_logger = logging.getLogger(__name__)
13+
14+
15+
class Locker:
16+
17+
def __init__(self, output_directory: pathlib.Path, root_namespace_name: str) -> None:
18+
self._output_directory = output_directory
19+
self._root_namespace_name = root_namespace_name
20+
self._lockfile: TextIOWrapper | None = None
21+
22+
@property
23+
def _lockfile_path(self) -> Path:
24+
return self._output_directory / f"{self._root_namespace_name}.lock"
25+
26+
def __enter__(self) -> bool:
27+
return self.create()
28+
29+
def __exit__(
30+
self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
31+
) -> None:
32+
if self._lockfile is not None:
33+
self.remove()
34+
35+
def create(self) -> bool:
36+
"""
37+
True means compilation needs to proceed.
38+
False means another process already compiled the namespace so we just waited for the lockfile to disappear before returning.
39+
"""
40+
try:
41+
pathlib.Path(self._output_directory).mkdir(parents=True, exist_ok=True)
42+
self._lockfile = open(self._lockfile_path, "x")
43+
_logger.debug("Created lockfile %s", self._lockfile_path)
44+
return True
45+
except FileExistsError:
46+
pass
47+
while pathlib.Path(self._lockfile_path).exists():
48+
_logger.debug("Waiting for lockfile %s", self._lockfile_path)
49+
time.sleep(1)
50+
51+
_logger.debug("Done waiting %s", self._lockfile_path)
52+
53+
return False
54+
55+
def remove(self) -> None:
56+
"""
57+
Invoking remove before creating lockfile is not allowed.
58+
"""
59+
assert self._lockfile is not None
60+
self._lockfile.close()
61+
pathlib.Path(self._lockfile_path).unlink()
62+
_logger.debug("Removed lockfile %s", self._lockfile_path)

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ ignore_missing_imports = True
149149
ignore_errors = True
150150
ignore_missing_imports = True
151151

152+
[mypy-test_dsdl_namespace.*]
153+
ignore_errors = True
154+
ignore_missing_imports = True
155+
152156
[mypy-numpy]
153157
ignore_errors = True
154158
ignore_missing_imports = True

tests/dsdl/_compiler.py

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
# This software is distributed under the terms of the MIT License.
33
# Author: Pavel Kirienko <pavel@opencyphal.org>
44

5+
import random
56
import sys
6-
import typing
7-
import logging
7+
import threading
8+
import time
89
import pathlib
910
import tempfile
1011
import pytest
1112
import pycyphal.dsdl
1213
from pycyphal.dsdl import remove_import_hooks, add_import_hook
13-
14+
from pycyphal.dsdl._lockfile import Locker
1415
from .conftest import DEMO_DIR
1516

1617

@@ -20,28 +21,6 @@ def _unittest_bad_usage() -> None:
2021
pycyphal.dsdl.compile("irrelevant", "irrelevant") # type: ignore
2122

2223

23-
def _unittest_module_import_path_usage_suggestion(caplog: typing.Any) -> None:
24-
caplog.set_level(logging.INFO)
25-
output_directory = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
26-
output_directory_name = pathlib.Path(output_directory.name).resolve()
27-
caplog.clear()
28-
pycyphal.dsdl.compile(DEMO_DIR / "public_regulated_data_types" / "uavcan", output_directory=output_directory.name)
29-
logs = caplog.record_tuples
30-
print("Captured log entries:", logs, sep="\n")
31-
for e in logs:
32-
if "dsdl" in e[0] and str(output_directory_name) in e[2]:
33-
assert e[1] == logging.INFO
34-
assert " path" in e[2]
35-
assert "Path(" not in e[2] # Ensure decent formatting
36-
break
37-
else:
38-
assert False
39-
try:
40-
output_directory.cleanup() # This may fail on Windows with Python 3.7, we don't care.
41-
except PermissionError: # pragma: no cover
42-
pass
43-
44-
4524
def _unittest_remove_import_hooks() -> None:
4625
from pycyphal.dsdl._import_hook import DsdlMetaFinder
4726

@@ -64,3 +43,28 @@ def _unittest_remove_import_hooks() -> None:
6443
def _unittest_issue_133() -> None:
6544
with pytest.raises(ValueError, match=".*output directory.*"):
6645
pycyphal.dsdl.compile(pathlib.Path.cwd() / "irrelevant")
46+
47+
48+
def _unittest_lockfile_cant_be_recreated() -> None:
49+
output_directory = pathlib.Path(tempfile.gettempdir())
50+
root_namespace_name = str(random.getrandbits(64))
51+
52+
lockfile1 = Locker(output_directory, root_namespace_name)
53+
lockfile2 = Locker(output_directory, root_namespace_name)
54+
55+
assert lockfile1.create() is True
56+
57+
def remove_lockfile1() -> None:
58+
time.sleep(5)
59+
lockfile1.remove()
60+
61+
threading.Thread(target=remove_lockfile1).start()
62+
assert lockfile2.create() is False
63+
64+
65+
def _unittest_lockfile_is_removed() -> None:
66+
output_directory = pathlib.Path(tempfile.gettempdir())
67+
68+
pycyphal.dsdl.compile(DEMO_DIR / "public_regulated_data_types" / "uavcan", output_directory=output_directory.name)
69+
70+
assert pathlib.Path.exists(output_directory / "uavcan.lock") is False

0 commit comments

Comments
 (0)