diff --git a/referencing/_core.py b/referencing/_core.py index 4206845..60b56e0 100644 --- a/referencing/_core.py +++ b/referencing/_core.py @@ -407,6 +407,78 @@ def __repr__(self) -> str: summary = f"{pluralized}" return f"" + def display_as_tree(self) -> str: + """ + Return a unicode tree representation of the resources in this registry. + + Useful for debugging what is contained in a registry. Resources are + grouped by their common URI prefixes and displayed using unicode + box-drawing characters. + + Example output:: + + http://example.com/ – Resource(...) + ├── foo/ – Resource(...) + │ ├── bar/ – Resource(...) + │ └── baz/ – Resource(...) + http://example.org/ – Resource(...) + + """ + uris = sorted(self._resources) + if not uris: + return "" + + lines: list[str] = [] + # Track roots: top-level URIs that are not prefixes of each other + roots: list[str] = [] + for uri in uris: + # A URI is a root if no existing root is a proper prefix of it + if not any(uri != root and uri.startswith(root) for root in roots): + roots.append(uri) + + def _render( + uri: str, + all_uris: list[str], + prefix: str = "", + connector: str = "", + ) -> None: + resource = self._resources.get(uri) + resource_repr = repr(resource) if resource is not None else "?" + lines.append(f"{prefix}{connector}{uri} \u2013 {resource_repr}") + + child_prefix = prefix + ( + " " + if connector == "\u2514\u2500\u2500 " + else "\u2502 " + if connector + else "" + ) + + children = [ + u + for u in all_uris + if u != uri + and u.startswith(uri) + and "/" not in u[len(uri) :].rstrip("/") + ] + children.sort() + for i, child in enumerate(children): + is_last = i == len(children) - 1 + child_connector = ( + "\u2514\u2500\u2500 " if is_last else "\u251c\u2500\u2500 " + ) + _render( + child, + all_uris, + prefix=child_prefix, + connector=child_connector, + ) + + for root in roots: + _render(root, uris, prefix="", connector="") + + return "\n".join(lines) + def get_or_retrieve(self, uri: URI) -> Retrieved[D, Resource[D]]: """ Get a resource from the registry, crawling or retrieving if necessary. diff --git a/referencing/tests/test_core.py b/referencing/tests/test_core.py index 3edddbc..c47c2e7 100644 --- a/referencing/tests/test_core.py +++ b/referencing/tests/test_core.py @@ -575,6 +575,52 @@ def test_repr_one_resource(self): def test_repr_empty(self): assert repr(Registry()) == "" + def test_display_as_tree_empty(self): + """ + An empty registry displays as ''. + """ + assert Registry().display_as_tree() == "" + + def test_display_as_tree_flat(self): + """ + A flat set of unrelated URIs renders each on its own line. + """ + one = Resource.opaque(contents={"title": "one"}) + two = Resource.opaque(contents={"title": "two"}) + registry = Registry().with_resources( + [ + ("http://example.com/one", one), + ("http://example.com/two", two), + ] + ) + tree = registry.display_as_tree() + assert "http://example.com/one" in tree + assert "http://example.com/two" in tree + + def test_display_as_tree_nested(self): + """ + URIs sharing a common prefix are rendered as a nested tree. + """ + root = Resource.opaque(contents={}) + child = Resource.opaque(contents={}) + grandchild = Resource.opaque(contents={}) + registry = Registry().with_resources( + [ + ("http://example.com/", root), + ("http://example.com/foo/", child), + ("http://example.com/foo/bar/", grandchild), + ] + ) + tree = registry.display_as_tree() + lines = tree.splitlines() + # Root appears first with no indentation connector + assert lines[0].startswith("http://example.com/") + # Nested children use box-drawing connectors + assert any( + "\u251c\u2500\u2500" in line or "\u2514\u2500\u2500" in line + for line in lines[1:] + ) + class TestResource: def test_from_contents_from_json_schema(self):