Skip to content

Commit 3ea74de

Browse files
Allow relative target paths (#110)
Fix for issue #109. This relaxes some logic that required existing paths before creating a DSDLFile abstraction while adding a requirement that ReadableDSDLFile(s) are created with files that exist (i.e. it can't be "readable" if it doesn't exist). At the same time, we clarified (and fixed) the difference between the `root_namespace_directories_or_names` and `lookup_directories arguments` of read_files. Our new guidance is to dis-use `lookup_directories` unless there is a need to separate target lookup paths from dependent type look up paths. This was an unstated design goal that I forgot about and subsequently mis-implemented. This does change the behaviour of the API slightly which is why I've bumped the minor version instead of just the patch.
1 parent c0afc34 commit 3ea74de

File tree

5 files changed

+239
-69
lines changed

5 files changed

+239
-69
lines changed

pydsdl/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sys as _sys
88
from pathlib import Path as _Path
99

10-
__version__ = "1.21.1"
10+
__version__ = "1.22.0"
1111
__version_info__ = tuple(map(int, __version__.split(".")[:3]))
1212
__license__ = "MIT"
1313
__author__ = "OpenCyphal"

pydsdl/_dsdl.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from __future__ import annotations
66
from abc import ABC, abstractmethod
77
from pathlib import Path
8-
from typing import Any, Callable, Iterable, TypeVar, List, Tuple
8+
from typing import Any, Callable, Iterable, List, Tuple, TypeVar
99

1010
from ._serializable import CompositeType, Version
1111

@@ -104,6 +104,14 @@ class ReadableDSDLFile(DSDLFile):
104104
A DSDL file that can construct a composite type from its contents.
105105
"""
106106

107+
@property
108+
@abstractmethod
109+
def file_path(self) -> Path:
110+
"""
111+
Path to an existing DSDL file on the filesystem.
112+
"""
113+
raise NotImplementedError()
114+
107115
@abstractmethod
108116
def read(
109117
self,

pydsdl/_dsdl_definition.py

Lines changed: 115 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class DSDLDefinition(ReadableDSDLFile):
4545
:param file_path: The path to the DSDL file.
4646
:param root_namespace_path: The path to the root namespace directory. `file_path` must be a descendant of this path.
4747
See `from_first_in` for a more flexible way to create a DSDLDefinition object.
48+
:raises InvalidDefinitionError: If file_path does not exist.
4849
"""
4950

5051
@classmethod
@@ -56,20 +57,37 @@ def _infer_path_to_root_from_first_found(cls, dsdl_path: Path, valid_dsdl_roots:
5657
if valid_dsdl_roots is None:
5758
raise ValueError("valid_dsdl_roots was None")
5859

59-
if dsdl_path.is_absolute() and len(valid_dsdl_roots) == 0:
60-
raise PathInferenceError(
61-
f"dsdl_path ({dsdl_path}) is absolute and the provided valid root names are empty. The DSDL root of "
62-
"an absolute path cannot be inferred without this information.",
63-
dsdl_path,
64-
valid_dsdl_roots,
65-
)
66-
60+
# INFERENCE 1: The easiest inference is when the target path is relative to the current working directory and
61+
# the root is a direct child folder. In this case we allow targets to be specified as simple, relative paths
62+
# where we infer the root from the first part of each path.
6763
if len(valid_dsdl_roots) == 0:
68-
# if we have no valid roots we can only infer the root of the path. We require the path to be relative
69-
# to avoid accidental inferences given that dsdl file trees starting from a filesystem root are rare.
70-
return Path(dsdl_path.parts[0])
71-
72-
# INFERENCE 1: The strongest inference is when the path is relative to a known root.
64+
# if we have no valid roots we can only infer the root of the path.
65+
if dsdl_path.is_absolute():
66+
# but if the path is absolute we refuse to infer the root as this would cause us to search the entire
67+
# filesystem for DSDL files and it's almost certainly wrong.
68+
raise PathInferenceError(
69+
f"No valid roots provided for absolute path {str(dsdl_path)}. Unable to infer root without "
70+
"more information.",
71+
dsdl_path,
72+
valid_dsdl_roots,
73+
)
74+
else:
75+
# if the path is relative we'll assume the root is the top-most folder in the path.
76+
directly_inferred = Path(dsdl_path.parts[0])
77+
try:
78+
directly_inferred.resolve(strict=True)
79+
except FileNotFoundError:
80+
raise PathInferenceError(
81+
f"No valid root found in path {str(dsdl_path)} and the inferred root {str(directly_inferred)} "
82+
"does not exist. You either need to change your working directory to the folder that contains "
83+
"this root folder or provide a valid root path.",
84+
dsdl_path,
85+
valid_dsdl_roots,
86+
)
87+
return directly_inferred
88+
89+
# INFERENCE 2: The next easiest inference is when the target path is relative to a known dsdl root. These
90+
# operations should work with pure paths and not require filesystem access.
7391
resolved_dsdl_path = dsdl_path.resolve(strict=False) if dsdl_path.is_absolute() else None
7492
for path_to_root in valid_dsdl_roots:
7593
# First we try the paths as-is...
@@ -89,7 +107,27 @@ def _infer_path_to_root_from_first_found(cls, dsdl_path: Path, valid_dsdl_roots:
89107
else:
90108
return path_to_root_resolved
91109

92-
# INFERENCE 2: A weaker, but valid inference is when the path is a child of a known root folder name.
110+
# INFERENCE 3: If the target is relative then we can try to find a valid root by looking for the file in the
111+
# root directories. This is a stronger inference than the previous one because it requires the file to exist
112+
# but we do it second because it reads the filesystem.
113+
if not dsdl_path.is_absolute():
114+
for path_to_root in valid_dsdl_roots:
115+
path_to_root_parent = path_to_root
116+
while path_to_root_parent != path_to_root_parent.parent:
117+
# Weld together and check only if the root's last part is the same name as the target's first part.
118+
# yes:
119+
# path/to/root + root/then/Type.1.0.dsdl <- /root == root/
120+
# no:
121+
# path/to/not_root + root/then/Type.1.0.dsdl <- /not_root != root/
122+
if (
123+
path_to_root_parent.parts[-1] == dsdl_path.parts[0]
124+
and (path_to_root_parent.parent / dsdl_path).exists()
125+
):
126+
return path_to_root_parent
127+
path_to_root_parent = path_to_root_parent.parent
128+
129+
# INFERENCE 4: A weaker, but valid inference is when the target path is a child of a known root folder name.
130+
# This is only allowed if dsdl roots are top-level namespace names and not paths.
93131
root_parts = [x.parts[-1] for x in valid_dsdl_roots if len(x.parts) == 1]
94132
parts = list(dsdl_path.parent.parts)
95133
for i, part in list(enumerate(parts)):
@@ -102,24 +140,38 @@ def _infer_path_to_root_from_first_found(cls, dsdl_path: Path, valid_dsdl_roots:
102140
def from_first_in(cls: Type["DSDLDefinition"], dsdl_path: Path, valid_dsdl_roots: list[Path]) -> "DSDLDefinition":
103141
"""
104142
Creates a DSDLDefinition object by inferring the path to the namespace root of a DSDL file given a set
105-
of valid roots. The logic used prefers an instance of dsdl_path found to exist under a valid root but
106-
will degrade to pure-path string matching if no file is found. Because this logic uses the first root path
107-
that passes one of these two inferences the order of the valid_dsdl_roots list matters.
143+
of valid roots and, if the dsdl path is relative, resolving the dsdl path relative to said roots. The logic used
144+
prefers an instance of `dsdl_path` found to exist under a valid root but will degrade to pure-path string
145+
matching if no file is found (If this does not yield a valid path to an existing dsdl file an exception is
146+
raised). Because this logic uses the first root path that passes one of these two inferences the order of the
147+
valid_dsdl_roots list matters.
108148
109149
:param dsdl_path: The path to the alleged DSDL file.
110150
:param valid_dsdl_roots: The ordered set of valid root names or paths under which the type must reside.
111151
This argument is accepted as a list for ordering but no de-duplication is performed
112152
as the caller is expected to provide a correct set of paths.
113153
:return A new DSDLDefinition object
114154
:raises PathInferenceError: If the namespace root cannot be inferred from the provided information.
155+
:raises InvalidDefinitionError: If the file does not exist.
115156
"""
116-
return cls(dsdl_path, cls._infer_path_to_root_from_first_found(dsdl_path, valid_dsdl_roots))
157+
root_path = cls._infer_path_to_root_from_first_found(dsdl_path, valid_dsdl_roots)
158+
if not dsdl_path.is_absolute():
159+
dsdl_path_resolved = (root_path.parent / dsdl_path).resolve(strict=False)
160+
else:
161+
dsdl_path_resolved = dsdl_path.resolve(strict=False)
162+
return cls(dsdl_path_resolved, root_path)
117163

118164
def __init__(self, file_path: Path, root_namespace_path: Path):
119165
""" """
120166
# Normalizing the path and reading the definition text
121167
self._file_path = Path(file_path)
122168
del file_path
169+
170+
if not self._file_path.exists():
171+
raise InvalidDefinitionError(
172+
"Attempt to construct ReadableDSDLFile object for file that doesn't exist.", self._file_path
173+
)
174+
123175
self._root_namespace_path = Path(root_namespace_path)
124176
del root_namespace_path
125177
self._text: str | None = None
@@ -171,8 +223,12 @@ def __init__(self, file_path: Path, root_namespace_path: Path):
171223
self._cached_type: CompositeType | None = None
172224

173225
# +-----------------------------------------------------------------------+
174-
# | ReadableDSDLFile :: INTERFACE |
226+
# | ReadableDSDLFile :: INTERFACE |
175227
# +-----------------------------------------------------------------------+
228+
@property
229+
def file_path(self) -> Path:
230+
return self._file_path
231+
176232
def read(
177233
self,
178234
lookup_definitions: Iterable[ReadableDSDLFile],
@@ -185,9 +241,6 @@ def read(
185241
_logger.debug("%s: Cache hit", log_prefix)
186242
return self._cached_type
187243

188-
if not self._file_path.exists():
189-
raise InvalidDefinitionError("Attempt to read DSDL file that doesn't exist.", self._file_path)
190-
191244
started_at = time.monotonic()
192245

193246
# Remove the target definition from the lookup list in order to prevent
@@ -274,10 +327,6 @@ def fixed_port_id(self) -> int | None:
274327
def has_fixed_port_id(self) -> bool:
275328
return self.fixed_port_id is not None
276329

277-
@property
278-
def file_path(self) -> Path:
279-
return self._file_path
280-
281330
@property
282331
def root_namespace_path(self) -> Path:
283332
return self._root_namespace_path
@@ -298,12 +347,15 @@ def __eq__(self, other: object) -> bool:
298347
return NotImplemented # pragma: no cover
299348

300349
def __str__(self) -> str:
301-
return "DSDLDefinition(full_name=%r, version=%r, fixed_port_id=%r, file_path=%s)" % (
302-
self.full_name,
303-
self.version,
304-
self.fixed_port_id,
305-
self.file_path,
306-
)
350+
try:
351+
return "DSDLDefinition(full_name=%r, version=%r, fixed_port_id=%r, file_path=%s)" % (
352+
self.full_name,
353+
self.version,
354+
self.fixed_port_id,
355+
self.file_path,
356+
)
357+
except AttributeError: # pragma: no cover
358+
return "DSDLDefinition(UNINITIALIZED)"
307359

308360
__repr__ = __str__
309361

@@ -315,13 +367,8 @@ def _unittest_dsdl_definition_read_non_existent() -> None:
315367
from pytest import raises as expect_raises
316368

317369
target = Path("root", "ns", "Target.1.1.dsdl")
318-
target_definition = DSDLDefinition(target, target.parent)
319-
320-
def print_output(line_number: int, text: str) -> None: # pragma: no cover
321-
_ = line_number, text
322-
323370
with expect_raises(InvalidDefinitionError):
324-
target_definition.read([], [], print_output, True)
371+
_ = DSDLDefinition(target, target.parent)
325372

326373

327374
def _unittest_dsdl_definition_read_text(temp_dsdl_factory) -> None: # type: ignore
@@ -354,16 +401,11 @@ def _unittest_type_from_path_inference() -> None:
354401
# case the method assumes that the relative path is the correct and complete namespace of the type:
355402

356403
# relative path
357-
root = DSDLDefinition._infer_path_to_root_from_first_found(Path("uavcan/foo/bar/435.baz.1.0.dsdl"), [])
404+
root = DSDLDefinition._infer_path_to_root_from_first_found(
405+
Path("uavcan/foo/bar/435.baz.1.0.dsdl"), [Path("uavcan")]
406+
)
358407
assert root == Path("uavcan")
359408

360-
# The root namespace is not inferred in an absolute path without additional data:
361-
362-
with expect_raises(PathInferenceError):
363-
_ = DSDLDefinition._infer_path_to_root_from_first_found(
364-
Path("/repo/uavcan/foo/bar/435.baz.1.0.dsdl").resolve(), []
365-
)
366-
367409
with expect_raises(ValueError):
368410
_ = DSDLDefinition._infer_path_to_root_from_first_found(
369411
Path("/repo/uavcan/foo/bar/435.baz.1.0.dsdl").resolve(), None # type: ignore
@@ -458,8 +500,31 @@ def _unittest_type_from_path_inference() -> None:
458500
assert root == Path("repo/uavcan")
459501

460502

461-
def _unittest_from_first_in() -> None:
462-
dsdl_def = DSDLDefinition.from_first_in(
463-
Path("/repo/uavcan/foo/bar/435.baz.1.0.dsdl").resolve(), [Path("/repo/uavcan/foo/..").resolve()]
464-
)
503+
def _unittest_type_from_path_inference_edge_case(temp_dsdl_factory) -> None: # type: ignore
504+
"""
505+
Edge case where we target a file where the namespace is under the root path.
506+
"""
507+
# pylint: disable=protected-access
508+
509+
from pytest import raises as expect_raises
510+
import os
511+
512+
target_path = Path("dsdl_root/Type.1.0.dsdl")
513+
target_file = temp_dsdl_factory.new_file(target_path, "@sealed").resolve()
514+
expected_root_parent = target_file.parent.parent
515+
with expect_raises(PathInferenceError):
516+
_ = DSDLDefinition._infer_path_to_root_from_first_found(target_file, [])
517+
518+
old_cwd = os.getcwd()
519+
os.chdir(expected_root_parent)
520+
try:
521+
root = DSDLDefinition._infer_path_to_root_from_first_found(target_path, [])
522+
assert root.parent.resolve() == expected_root_parent
523+
finally:
524+
os.chdir(old_cwd)
525+
526+
527+
def _unittest_from_first_in(temp_dsdl_factory) -> None: # type: ignore
528+
dsdl_file = temp_dsdl_factory.new_file(Path("repo/uavcan/foo/bar/435.baz.1.0.dsdl"), "@sealed")
529+
dsdl_def = DSDLDefinition.from_first_in(dsdl_file.resolve(), [(dsdl_file.parent.parent / "..")])
465530
assert dsdl_def.full_name == "uavcan.foo.bar.baz"

0 commit comments

Comments
 (0)