Skip to content

Commit f200722

Browse files
jclermanaucampia
andauthored
feat: add curie method to NamespaceManager (#2365)
Added a `curie` method to `NamespaceManager`, which can be used to generate a CURIE from a URI. Other changes: - Fixed `NamespaceManager.expand_curie` to work with CURIES that have blank prefixes (e.g. `:something`), which are valid according to [CURIE Syntax 1.0](https://www.w3.org/TR/2010/NOTE-curie-20101216/). - Added a test to confirm <#2077>. Fixes <#2348>. --------- Co-authored-by: Iwan Aucamp <aucampia@gmail.com>
1 parent ddcc4eb commit f200722

File tree

3 files changed

+179
-9
lines changed

3 files changed

+179
-9
lines changed

rdflib/namespace/__init__.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,35 @@ def qname(self, uri: str) -> str:
490490
else:
491491
return ":".join((prefix, name))
492492

493+
def curie(self, uri: str, generate: bool = True) -> str:
494+
"""
495+
From a URI, generate a valid CURIE.
496+
497+
Result is guaranteed to contain a colon separating the prefix from the
498+
name, even if the prefix is an empty string.
499+
500+
.. warning::
501+
502+
When ``generate`` is `True` (which is the default) and there is no
503+
matching namespace for the URI in the namespace manager then a new
504+
namespace will be added with prefix ``ns{index}``.
505+
506+
Thus, when ``generate`` is `True`, this function is not a pure
507+
function because of this side-effect.
508+
509+
This default behaviour is chosen so that this function operates
510+
similarly to `NamespaceManager.qname`.
511+
512+
:param uri: URI to generate CURIE for.
513+
:param generate: Whether to add a prefix for the namespace if one doesn't
514+
already exist. Default: `True`.
515+
:return: CURIE for the URI.
516+
:raises KeyError: If generate is `False` and the namespace doesn't already have
517+
a prefix.
518+
"""
519+
prefix, namespace, name = self.compute_qname(uri, generate=generate)
520+
return ":".join((prefix, name))
521+
493522
def qname_strict(self, uri: str) -> str:
494523
prefix, namespace, name = self.compute_qname_strict(uri)
495524
if prefix == "":
@@ -643,7 +672,7 @@ def expand_curie(self, curie: str) -> URIRef:
643672
if not type(curie) is str:
644673
raise TypeError(f"Argument must be a string, not {type(curie).__name__}.")
645674
parts = curie.split(":", 1)
646-
if len(parts) != 2 or len(parts[0]) < 1:
675+
if len(parts) != 2:
647676
raise ValueError(
648677
"Malformed curie argument, format should be e.g. “foaf:name”."
649678
)

test/test_namespace/test_namespacemanager.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import sys
66
from contextlib import ExitStack
77
from pathlib import Path
8+
from test.utils.exceptions import ExceptionChecker
89
from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Set, Tuple, Type, Union
910

1011
import pytest
@@ -484,3 +485,115 @@ def check() -> None:
484485
check()
485486
# Run a second time to check caching
486487
check()
488+
489+
490+
def make_test_nsm() -> NamespaceManager:
491+
namespaces = [
492+
("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"),
493+
("", "http://example.org/"),
494+
(
495+
# Because of <https://github.com/RDFLib/rdflib/issues/2077> this
496+
# will have no effect on the namespace manager.
497+
"eg",
498+
"http://example.org/",
499+
),
500+
]
501+
graph = Graph(bind_namespaces="none")
502+
for prefix, namespace in namespaces:
503+
graph.bind(prefix, namespace, override=False)
504+
505+
return graph.namespace_manager
506+
507+
508+
@pytest.fixture(scope="session")
509+
def test_nsm_session() -> NamespaceManager:
510+
return make_test_nsm()
511+
512+
513+
@pytest.fixture(scope="function")
514+
def test_nsm_function() -> NamespaceManager:
515+
return make_test_nsm()
516+
517+
518+
@pytest.mark.parametrize(
519+
["curie", "expected_result"],
520+
[
521+
("rdf:type", "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"),
522+
(":foo", "http://example.org/foo"),
523+
("too_small", ExceptionChecker(ValueError, "Malformed curie argument")),
524+
(
525+
"egdo:bar",
526+
ExceptionChecker(ValueError, 'Prefix "egdo" not bound to any namespace'),
527+
),
528+
pytest.param(
529+
"eg:foo",
530+
"http://example.org/foo",
531+
marks=pytest.mark.xfail(
532+
raises=ValueError,
533+
reason="This is failing because of https://github.com/RDFLib/rdflib/issues/2077",
534+
),
535+
),
536+
],
537+
)
538+
def test_expand_curie(
539+
test_nsm_session: NamespaceManager,
540+
curie: str,
541+
expected_result: Union[ExceptionChecker, str],
542+
) -> None:
543+
nsm = test_nsm_session
544+
with ExitStack() as xstack:
545+
if isinstance(expected_result, ExceptionChecker):
546+
xstack.enter_context(expected_result)
547+
result = nsm.expand_curie(curie)
548+
549+
if not isinstance(expected_result, ExceptionChecker):
550+
assert URIRef(expected_result) == result
551+
552+
553+
@pytest.mark.parametrize(
554+
["uri", "generate", "expected_result"],
555+
[
556+
("http://www.w3.org/1999/02/22-rdf-syntax-ns#type", None, "rdf:type"),
557+
("http://example.org/foo", None, ":foo"),
558+
("http://example.com/a#chair", None, "ns1:chair"),
559+
("http://example.com/a#chair", True, "ns1:chair"),
560+
(
561+
"http://example.com/a#chair",
562+
False,
563+
ExceptionChecker(
564+
KeyError, "No known prefix for http://example.com/a# and generate=False"
565+
),
566+
),
567+
("http://example.com/b#chair", None, "ns1:chair"),
568+
("http://example.com/c", None, "ns1:c"),
569+
("", None, ExceptionChecker(ValueError, "Can't split ''")),
570+
(
571+
"http://example.com/",
572+
None,
573+
ExceptionChecker(ValueError, "Can't split 'http://example.com/'"),
574+
),
575+
],
576+
)
577+
def test_generate_curie(
578+
test_nsm_function: NamespaceManager,
579+
uri: str,
580+
generate: Optional[bool],
581+
expected_result: Union[ExceptionChecker, str],
582+
) -> None:
583+
"""
584+
.. note::
585+
586+
This is using the function scoped nsm fixture because curie has side
587+
effects and will modify the namespace manager.
588+
"""
589+
nsm = test_nsm_function
590+
with ExitStack() as xstack:
591+
if isinstance(expected_result, ExceptionChecker):
592+
xstack.enter_context(expected_result)
593+
if generate is None:
594+
result = nsm.curie(uri)
595+
else:
596+
result = nsm.curie(uri, generate=generate)
597+
598+
if not isinstance(expected_result, ExceptionChecker):
599+
assert expected_result == result

test/utils/exceptions.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,32 @@
1+
from __future__ import annotations
2+
13
import logging
24
import re
35
from dataclasses import dataclass
4-
from typing import Any, Dict, Optional, Pattern, Type, Union
6+
from types import TracebackType
7+
from typing import Any, ContextManager, Dict, Optional, Pattern, Type, Union
8+
9+
import pytest
10+
from pytest import ExceptionInfo
511

612

7-
@dataclass(frozen=True)
8-
class ExceptionChecker:
13+
@dataclass
14+
class ExceptionChecker(ContextManager[ExceptionInfo[Exception]]):
915
type: Type[Exception]
1016
pattern: Optional[Union[Pattern[str], str]] = None
1117
attributes: Optional[Dict[str, Any]] = None
1218

19+
def __post_init__(self) -> None:
20+
self._catcher = pytest.raises(self.type, match=self.pattern)
21+
self._exception_info: Optional[ExceptionInfo[Exception]] = None
22+
23+
def _check_attributes(self, exception: Exception) -> None:
24+
if self.attributes is not None:
25+
for key, value in self.attributes.items():
26+
logging.debug("checking exception attribute %s=%r", key, value)
27+
assert hasattr(exception, key)
28+
assert getattr(exception, key) == value
29+
1330
def check(self, exception: Exception) -> None:
1431
logging.debug("checking exception %s/%r", type(exception), exception)
1532
pattern = self.pattern
@@ -19,11 +36,22 @@ def check(self, exception: Exception) -> None:
1936
assert isinstance(exception, self.type)
2037
if pattern is not None:
2138
assert pattern.match(f"{exception}")
22-
if self.attributes is not None:
23-
for key, value in self.attributes.items():
24-
logging.debug("checking exception attribute %s=%r", key, value)
25-
assert hasattr(exception, key)
26-
assert getattr(exception, key) == value
39+
self._check_attributes(exception)
2740
except Exception:
2841
logging.error("problem checking exception", exc_info=exception)
2942
raise
43+
44+
def __enter__(self) -> ExceptionInfo[Exception]:
45+
self._exception_info = self._catcher.__enter__()
46+
return self._exception_info
47+
48+
def __exit__(
49+
self,
50+
__exc_type: Optional[Type[BaseException]],
51+
__exc_value: Optional[BaseException],
52+
__traceback: Optional[TracebackType],
53+
) -> bool:
54+
result = self._catcher.__exit__(__exc_type, __exc_value, __traceback)
55+
if self._exception_info is not None:
56+
self._check_attributes(self._exception_info.value)
57+
return result

0 commit comments

Comments
 (0)