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: DefinedNamespace: fixed handling of control attributes. #1825

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 27 additions & 3 deletions rdflib/namespace/__init__.py
Expand Up @@ -3,7 +3,7 @@
import warnings
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Tuple, Union
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Set, Tuple, Union
from unicodedata import category
from urllib.parse import urldefrag, urljoin

Expand Down Expand Up @@ -190,6 +190,19 @@ def __repr__(self) -> str:
return f"URIPattern({super().__repr__()})"


# _DFNS_RESERVED_ATTRS are attributes for which DefinedNamespaceMeta should
# always raise AttributeError if they are not defined and which should not be
# considered part of __dir__ results. These should be all annotations on
# `DefinedNamespaceMeta`.
_DFNS_RESERVED_ATTRS: Set[str] = {
"_NS",
"_warn",
"_fail",
"_extras",
"_underscore_num",
}


class DefinedNamespaceMeta(type):
"""Utility metaclass for generating URIRefs with a common prefix."""

Expand All @@ -202,7 +215,13 @@ class DefinedNamespaceMeta(type):
@lru_cache(maxsize=None)
def __getitem__(cls, name: str, default=None) -> URIRef:
name = str(name)
if name in _DFNS_RESERVED_ATTRS:
raise AttributeError(
f"DefinedNamespace like object has no attribute {name!r}"
)
if str(name).startswith("__"):
# NOTE on type ignore: This seems to be a real bug, super() does not
# implement this method, it will fail if it is ever reached.
return super().__getitem__(name, default) # type: ignore[misc] # undefined in superclass
if (cls._warn or cls._fail) and name not in cls:
if cls._fail:
Expand All @@ -218,7 +237,7 @@ def __getattr__(cls, name: str):
return cls.__getitem__(name)

def __repr__(cls) -> str:
return f'Namespace("{cls._NS}")'
return f'Namespace({str(cls._NS)!r})'

def __str__(cls) -> str:
return str(cls._NS)
Expand All @@ -230,6 +249,8 @@ def __contains__(cls, item: str) -> bool:
"""Determine whether a URI or an individual item belongs to this namespace"""
item_str = str(item)
if item_str.startswith("__"):
# NOTE on type ignore: This seems to be a real bug, super() does not
# implement this method, it will fail if it is ever reached.
return super().__contains__(item) # type: ignore[misc] # undefined in superclass
if item_str.startswith(str(cls._NS)):
item_str = item_str[len(str(cls._NS)) :]
Expand All @@ -242,7 +263,10 @@ def __contains__(cls, item: str) -> bool:
)

def __dir__(cls) -> Iterable[str]:
values = {cls[str(x)] for x in cls.__annotations__}
attrs = {str(x) for x in cls.__annotations__}
# Removing these as they should not be considered part of the namespace.
attrs.difference_update(_DFNS_RESERVED_ATTRS)
values = {cls[str(x)] for x in attrs}
return values

def as_jsonld_context(self, pfx: str) -> dict:
Expand Down
239 changes: 239 additions & 0 deletions test/test_namespace/test_definednamespace.py
@@ -1,10 +1,20 @@
import inspect
import json
import logging
import subprocess
import sys
import warnings
from contextlib import ExitStack
from dataclasses import dataclass
from pathlib import Path
from test.data import TEST_DATA_DIR
from typing import Optional, Type

import pytest

from rdflib import RDF, SKOS
from rdflib.namespace import DefinedNamespace, Namespace
from rdflib.term import URIRef


def test_definednamespace_creator_qb():
Expand Down Expand Up @@ -191,3 +201,232 @@ def test_definednamespace_jsonld_context():
actual = SKOS.as_jsonld_context("skos")

assert actual == expected


prefix = "http://example.com/"


class DFNSNoNS(DefinedNamespace):
defined: URIRef
_defined: URIRef


class DFNSDefaults(DefinedNamespace):
_NS = Namespace(f"{prefix}DFNSDefaults#")
defined: URIRef
_defined: URIRef


class DFNSDefaultsEmpty(DefinedNamespace):
_NS = Namespace(f"{prefix}DFNSDefaultsEmpty#")


class DFNSWarnFailEmpty(DefinedNamespace):
_NS = Namespace(f"{prefix}DFNSWarnFailEmpty#")
_warn = True
_fail = True


class DFNSNoWarnNoFail(DefinedNamespace):
_NS = Namespace(f"{prefix}DFNSNoWarnNoFail#")
_warn = False
_fail = False
defined: URIRef
_defined: URIRef


class DFNSNoWarnFail(DefinedNamespace):
_NS = Namespace(f"{prefix}DFNSNoWarnFail#")
_warn = False
_fail = True
defined: URIRef
_defined: URIRef


class DFNSWarnNoFail(DefinedNamespace):
_NS = Namespace(f"{prefix}DFNSWarnNoFail#")
_warn = True
_fail = False
defined: URIRef
_defined: URIRef


class DFNSWarnFail(DefinedNamespace):
_NS = Namespace(f"{prefix}DFNSWarnFail#")
_warn = True
_fail = True
defined: URIRef
_defined: URIRef


@dataclass
class DFNSInfo:
dfns: Type[DefinedNamespace]
suffix: Optional[str]
has_attrs: bool = True


dfns_infos = [
DFNSInfo(DFNSNoNS, None),
DFNSInfo(DFNSDefaults, "DFNSDefaults#"),
DFNSInfo(DFNSNoWarnNoFail, "DFNSNoWarnNoFail#"),
DFNSInfo(DFNSWarnFail, "DFNSWarnFail#"),
DFNSInfo(DFNSNoWarnFail, "DFNSNoWarnFail#"),
DFNSInfo(DFNSWarnNoFail, "DFNSWarnNoFail#"),
DFNSInfo(DFNSDefaultsEmpty, "DFNSDefaultsEmpty#", False),
DFNSInfo(DFNSWarnFailEmpty, "DFNSWarnFailEmpty#", False),
DFNSInfo(DefinedNamespace, None, False),
]
dfns_list = [item.dfns for item in dfns_infos]


def get_dfns_info(dfns: Type[DefinedNamespace]) -> DFNSInfo:
for dfns_info in dfns_infos:
if dfns_info.dfns is dfns:
return dfns_info
raise ValueError("No DFNSInfo for the DefinedNamespace passed in ...")


@pytest.fixture(
scope="module",
params=[item.dfns for item in dfns_infos],
)
def dfns(request) -> DFNSInfo:
assert issubclass(request.param, DefinedNamespace)
return request.param


def test_repr(dfns: Type[DefinedNamespace]) -> None:
dfns_info = get_dfns_info(dfns)
ns_uri = f"{prefix}{dfns_info.suffix}"
logging.debug("ns_uri = %s", ns_uri)

repr_str: Optional[str] = None

with ExitStack() as xstack:
if dfns_info.suffix is None:
xstack.enter_context(pytest.raises(AttributeError))
repr_str = f"{dfns_info.dfns!r}"
if dfns_info.suffix is None:
assert repr_str is None
else:
assert repr_str is not None
repro = eval(repr_str)
assert ns_uri == f"{repro}"


def test_inspect(dfns: Type[DefinedNamespace]) -> None:
"""
`inspect.signature` returns. This is here to check that this does not
trigger infinite recursion.
"""
inspect.signature(dfns, follow_wrapped=True)


@pytest.mark.parametrize(
["attr_name", "is_defined"],
[
("defined", True),
("_defined", True),
("notdefined", False),
("_notdefined", False),
],
)
def test_value(dfns: Type[DefinedNamespace], attr_name: str, is_defined: bool) -> None:
dfns_info = get_dfns_info(dfns)
if dfns_info.has_attrs is False:
is_defined = False
resolved: Optional[str] = None
with ExitStack() as xstack:
warnings_record = xstack.enter_context(warnings.catch_warnings(record=True))
if dfns_info.suffix is None or (not is_defined and dfns._fail is True):
xstack.enter_context(pytest.raises(AttributeError))
resolved = eval(f'dfns.{attr_name}')
if dfns_info.suffix is not None:
if is_defined or dfns._fail is False:
assert f"{prefix}{dfns_info.suffix}{attr_name}" == f"{resolved}"
else:
assert resolved is None
if dfns._warn is False:
assert len(warnings_record) == 0
elif not is_defined and resolved is not None:
assert len(warnings_record) == 1
else:
assert resolved is None


@pytest.mark.parametrize(
["attr_name", "is_defined"],
[
("defined", True),
("_defined", True),
("notdefined", False),
("_notdefined", False),
],
)
def test_contains(
dfns: Type[DefinedNamespace], attr_name: str, is_defined: bool
) -> None:
dfns_info = get_dfns_info(dfns)
if dfns_info.suffix is not None:
logging.debug("dfns_info = %s", dfns_info)
if dfns_info.has_attrs is False:
is_defined = False
does_contain: Optional[bool] = None
with ExitStack() as xstack:
if dfns_info.suffix is None:
xstack.enter_context(pytest.raises(AttributeError))
does_contain = attr_name in dfns
if dfns_info.suffix is not None:
if is_defined:
assert does_contain is True
else:
assert does_contain is False
else:
assert does_contain is None


@pytest.mark.parametrize(
["attr_name", "is_defined"],
[
("defined", True),
("_defined", True),
("notdefined", False),
("_notdefined", False),
],
)
def test_hasattr(
dfns: Type[DefinedNamespace], attr_name: str, is_defined: bool
) -> None:
dfns_info = get_dfns_info(dfns)
if dfns_info.suffix is not None:
logging.debug("dfns_info = %s", dfns_info)
if dfns_info.has_attrs is False:
is_defined = False
has_attr: Optional[bool] = None
has_attr = hasattr(dfns, attr_name)
if dfns_info.suffix is not None and (is_defined or dfns._fail is False):
assert has_attr is True
else:
assert has_attr is False


def test_dir(dfns: Type[DefinedNamespace]) -> None:
dfns_info = get_dfns_info(dfns)
does_contain: Optional[bool] = None
with ExitStack() as xstack:
# dir should work for DefinedNamespace as this is called by sphinx to
# document it.
if dfns_info.suffix is None and dfns is not DefinedNamespace:
xstack.enter_context(pytest.raises(AttributeError))
attrs = list(dir(dfns))
if dfns_info.suffix is not None:
if dfns_info.has_attrs:
assert set(attrs) == {
URIRef(f"{prefix}{dfns_info.suffix}defined"),
URIRef(f"{prefix}{dfns_info.suffix}_defined"),
}
else:
assert list(attrs) == []
else:
assert does_contain is None