Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix for Issue/104 #106

Closed
wants to merge 11 commits into from
31 changes: 28 additions & 3 deletions pydsdl/_data_type_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ class MissingSerializationModeError(_error.InvalidDefinitionError):
pass


class DataTypeCollisionError(_error.InvalidDefinitionError):
"""
Raised when there are conflicting data type definitions.
"""


class DataTypeNameCollisionError(DataTypeCollisionError):
"""
Raised when type collisions are caused by naming conflicts.
"""


_logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -196,7 +208,11 @@ def resolve_versioned_data_type(self, name: str, version: _serializable.Version)
_logger.debug("The full name of a relatively referred type %r reconstructed as %r", name, full_name)

del name
found = list(filter(lambda d: d.full_name == full_name and d.version == version, self._lookup_definitions))
found = list(
filter(
lambda d: d.full_name.lower() == full_name.lower() and d.version == version, self._lookup_definitions
)
)
if not found:
# Play Sherlock to help the user with mistakes like https://forum.opencyphal.org/t/904/2
requested_ns = full_name.split(_serializable.CompositeType.NAME_COMPONENT_SEPARATOR)[0]
Expand All @@ -217,8 +233,17 @@ def resolve_versioned_data_type(self, name: str, version: _serializable.Version)
error_description += " Please make sure that you specified the directories correctly."
raise UndefinedDataTypeError(error_description)

if len(found) > 1: # pragma: no cover
raise _error.InternalError("Conflicting definitions: %r" % found)
if len(found) > 1:
if (
found[0].full_name != found[1].full_name and found[0].full_name.lower() == found[1].full_name.lower()
): # pragma: no cover
# This only happens if the file system is case-sensitive.
raise DataTypeNameCollisionError(
"Full name of this definition differs from %s only by letter case, "
"which is not permitted" % found[0].file_path,
path=found[1].file_path,
)
raise DataTypeCollisionError("Conflicting definitions: %r" % found)

target_definition = found[0]
for visitor in self._definition_visitors:
Expand Down
83 changes: 16 additions & 67 deletions pydsdl/_namespace.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,6 @@ class RootNamespaceNameCollisionError(_error.InvalidDefinitionError):
"""


class DataTypeCollisionError(_error.InvalidDefinitionError):
"""
Raised when there are conflicting data type definitions.
"""


class DataTypeNameCollisionError(DataTypeCollisionError):
"""
Raised when there are conflicting data type names.
"""


class NestedRootNamespaceError(_error.InvalidDefinitionError):
"""
Nested root namespaces are not allowed. This exception is thrown when this rule is violated.
Expand Down Expand Up @@ -259,9 +247,6 @@ def _complete_read_function(

lookup_dsdl_definitions = _construct_dsdl_definitions_from_namespaces(lookup_directories_path_list)

# Check for collisions against the lookup definitions also.
_ensure_no_collisions(target_dsdl_definitions, lookup_dsdl_definitions)

_logger.debug("Lookup DSDL definitions are listed below:")
for x in lookup_dsdl_definitions:
_logger.debug(_LOG_LIST_ITEM_PREFIX + str(x))
Expand Down Expand Up @@ -385,44 +370,6 @@ def _construct_dsdl_definitions_from_namespaces(
return dsdl_file_sort([_dsdl_definition.DSDLDefinition(*p) for p in source_file_paths])


def _ensure_no_collisions(
target_definitions: List[DsdlFileBuildable],
lookup_definitions: List[DsdlFileBuildable],
) -> None:
for tg in target_definitions:
tg_full_namespace_period = tg.full_namespace.lower() + "."
tg_full_name_period = tg.full_name.lower() + "."
for lu in lookup_definitions:
lu_full_namespace_period = lu.full_namespace.lower() + "."
lu_full_name_period = lu.full_name.lower() + "."
# This is to allow the following messages to coexist happily:
# zubax/non_colliding/iceberg/Ice.0.1.dsdl
# zubax/non_colliding/IceB.0.1.dsdl
# The following is still not allowed:
# zubax/colliding/iceberg/Ice.0.1.dsdl
# zubax/colliding/Iceberg.0.1.dsdl
if tg.full_name != lu.full_name and tg.full_name.lower() == lu.full_name.lower():
raise DataTypeNameCollisionError(
"Full name of this definition differs from %s only by letter case, "
"which is not permitted" % lu.file_path,
path=tg.file_path,
)
if (tg_full_namespace_period).startswith(lu_full_name_period):
raise DataTypeNameCollisionError(
"The namespace of this type conflicts with %s" % lu.file_path, path=tg.file_path
)
if (lu_full_namespace_period).startswith(tg_full_name_period):
raise DataTypeNameCollisionError(
"This type conflicts with the namespace of %s" % lu.file_path, path=tg.file_path
)
if (
tg_full_name_period == lu_full_name_period
and tg.version == lu.version
and not tg.file_path.samefile(lu.file_path)
): # https://github.com/OpenCyphal/pydsdl/issues/94
raise DataTypeCollisionError("This type is redefined in %s" % lu.file_path, path=tg.file_path)


def _ensure_no_fixed_port_id_collisions(types: List[_serializable.CompositeType]) -> None:
for a in types:
for b in types:
Expand Down Expand Up @@ -864,20 +811,22 @@ def _unittest_read_files_empty_args() -> None:
assert len(transitive) == 0


def _unittest_ensure_no_collisions(temp_dsdl_factory) -> None: # type: ignore
from pytest import raises as expect_raises

# gratuitous coverage of the collision check where other tests don't cover some edge cases
def _unittest_ensure_no_namespace_name_collisions_or_nested_root_namespaces() -> None:
"""gratuitous coverage of the collision check where other tests don't cover some edge cases."""
_ensure_no_namespace_name_collisions_or_nested_root_namespaces([], False)

with expect_raises(DataTypeNameCollisionError):
_ensure_no_collisions(
[_dsdl_definition.DSDLDefinition(Path("a/b.1.0.dsdl"), Path("a"))],
[_dsdl_definition.DSDLDefinition(Path("a/B.1.0.dsdl"), Path("a"))],
)

with expect_raises(DataTypeNameCollisionError):
_ensure_no_collisions(
[_dsdl_definition.DSDLDefinition(Path("a/b/c.1.0.dsdl"), Path("a"))],
[_dsdl_definition.DSDLDefinition(Path("a/b.1.0.dsdl"), Path("a"))],
)
def _unittest_issue_104(temp_dsdl_factory) -> None: # type: ignore
"""demonstrate that removing _ensure_no_collisions is okay"""

thing_1_0 = Path("a/b/thing.1.0.dsdl")
thing_type_1_0 = Path("a/b/thing/thingtype.1.0.dsdl")

file_at_root = temp_dsdl_factory.new_file(Path("a/Nothing.1.0.dsdl"), "@sealed\n")
thing_file = temp_dsdl_factory.new_file(thing_1_0, "@sealed\na.b.thing.thingtype.1.0 thing\n")
_ = temp_dsdl_factory.new_file(thing_type_1_0, "@sealed\n")

direct, transitive = read_files(thing_file, file_at_root.parent, file_at_root.parent)

assert len(direct) == 1
assert len(transitive) == 1
97 changes: 73 additions & 24 deletions pydsdl/_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pathlib import Path
from textwrap import dedent
import pytest # This is only safe to import in test files!
from . import _data_type_builder
from . import _expression
from . import _error
from . import _parser
Expand All @@ -24,6 +25,8 @@
class Workspace:
def __init__(self) -> None:
self._tmp_dir = tempfile.TemporaryDirectory(prefix="pydsdl-test-")
name = self._tmp_dir.name.upper()
self._is_case_sensitive = not Path(name).exists()

@property
def directory(self) -> Path:
Expand Down Expand Up @@ -57,6 +60,10 @@ def drop(self, rel_path_glob: str) -> None:
for g in self.directory.glob(rel_path_glob):
g.unlink()

@property
def is_fs_case_sensitive(self) -> bool:
return self._is_case_sensitive


def parse_definition(
definition: _dsdl_definition.DSDLDefinition, lookup_definitions: Sequence[_dsdl_definition.DSDLDefinition]
Expand Down Expand Up @@ -1095,7 +1102,7 @@ def print_handler(d: Path, line: int, text: str) -> None:
"""
),
)
with raises(_namespace.DataTypeNameCollisionError):
with raises(_namespace.FixedPortIDCollisionError):
_namespace.read_namespace(
wrkspc.directory / "zubax",
[
Expand All @@ -1104,30 +1111,27 @@ def print_handler(d: Path, line: int, text: str) -> None:
)

# Do again to test single lookup-directory override
with raises(_namespace.DataTypeNameCollisionError):
with raises(_namespace.FixedPortIDCollisionError):
_namespace.read_namespace(wrkspc.directory / "zubax", wrkspc.directory / "zubax")

try:
(wrkspc.directory / "zubax/colliding/iceberg/300.Ice.30.0.dsdl").unlink()
wrkspc.new(
"zubax/COLLIDING/300.Iceberg.30.0.dsdl",
dedent(
"""
@extent 1024
---
@extent 1024
(wrkspc.directory / "zubax/colliding/iceberg/300.Ice.30.0.dsdl").unlink()
wrkspc.new(
"zubax/COLLIDING/300.Iceberg.30.0.dsdl",
dedent(
"""
),
)
with raises(_namespace.DataTypeNameCollisionError, match=".*letter case.*"):
_namespace.read_namespace(
@extent 1024
---
@extent 1024
"""
),
)
with raises(_namespace.FixedPortIDCollisionError):
_namespace.read_namespace(
wrkspc.directory / "zubax",
[
wrkspc.directory / "zubax",
[
wrkspc.directory / "zubax",
],
)
except _namespace.FixedPortIDCollisionError: # pragma: no cover
pass # We're running on a platform where paths are not case-sensitive.
],
)

# Test namespace can intersect with type name
(wrkspc.directory / "zubax/COLLIDING/300.Iceberg.30.0.dsdl").unlink()
Expand Down Expand Up @@ -1160,6 +1164,52 @@ def print_handler(d: Path, line: int, text: str) -> None:
assert "zubax.noncolliding.Iceb" in [x.full_name for x in parsed]


def _unittest_collision_on_case_sensitive_filesystem(wrkspc: Workspace) -> None:
from pytest import raises

if not wrkspc.is_fs_case_sensitive: # pragma: no cover
pytest.skip("This test is only relevant on case-sensitive filesystems.")

# Empty namespace.
assert [] == _namespace.read_namespace(wrkspc.directory)

wrkspc.new(
"atlantic/ships/Titanic.1.0.dsdl",
dedent(
"""
greenland.colliding.IceBerg.1.0[<=2] bergs
@sealed
"""
),
)

wrkspc.new(
"greenland/colliding/IceBerg.1.0.dsdl",
dedent(
"""
@sealed
"""
),
)

wrkspc.new(
"greenland/COLLIDING/IceBerg.1.0.dsdl",
Comment on lines +1187 to +1196

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two should collide as we can't trust capitalization of the File system?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This goes to stability. The assertion is the outputs of pydsdl should be the same for a given set of inputs if run on a case-sensitive or case-insensitive filesystem. Because these are two different files on one and the same file on another we prevent it to avoid this ambiguity.

dedent(
"""
@sealed
"""
),
)

with raises(_data_type_builder.DataTypeNameCollisionError, match=".*letter case.*"):
_namespace.read_namespace(
wrkspc.directory / "atlantic",
[
wrkspc.directory / "greenland",
],
)


def _unittest_parse_namespace_versioning(wrkspc: Workspace) -> None:
from pytest import raises

Expand Down Expand Up @@ -1264,8 +1314,7 @@ def _unittest_parse_namespace_versioning(wrkspc: Workspace) -> None:
),
)

with raises(_namespace.DataTypeCollisionError):
_namespace.read_namespace((wrkspc.directory / "ns"), [])
_namespace.read_namespace((wrkspc.directory / "ns"), [])

wrkspc.drop("ns/Spartans.30.2.dsdl")

Expand Down Expand Up @@ -1613,7 +1662,7 @@ def _unittest_issue94(wrkspc: Workspace) -> None:
wrkspc.new("outer_b/ns/Foo.1.0.dsdl", "@sealed") # Conflict!
wrkspc.new("outer_a/ns/Bar.1.0.dsdl", "Foo.1.0 fo\n@sealed") # Which Foo.1.0?

with raises(_namespace.DataTypeCollisionError):
with raises(_data_type_builder.DataTypeCollisionError):
_namespace.read_namespace(
wrkspc.directory / "outer_a" / "ns",
[wrkspc.directory / "outer_b" / "ns"],
Expand Down