ttl3d 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ttl3d-0.2.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soheil Abadifard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ttl3d-0.2.2/PKG-INFO ADDED
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: ttl3d
3
+ Version: 0.2.2
4
+ Summary: Turn any Turtle / RDF graph into a self-contained, offline 2D/3D HTML viewer
5
+ Author: Soheil Abadifard
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/soheilabadifard/TTL_to_3D
8
+ Project-URL: Issues, https://github.com/soheilabadifard/TTL_to_3D/issues
9
+ Keywords: rdf,turtle,owl,ontology,knowledge-graph,semantic-web,linked-data,visualization,3d,2d,graph-visualization
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Visualization
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: rdflib>=7
24
+ Requires-Dist: networkx>=3
25
+ Requires-Dist: numpy
26
+ Requires-Dist: scipy
27
+ Provides-Extra: test
28
+ Requires-Dist: pytest; extra == "test"
29
+ Provides-Extra: browser
30
+ Requires-Dist: pytest; extra == "browser"
31
+ Requires-Dist: playwright; extra == "browser"
32
+ Dynamic: license-file
33
+
34
+ # ttl3d
35
+
36
+ [![tests](https://github.com/soheilabadifard/TTL_to_3D/actions/workflows/tests.yml/badge.svg)](https://github.com/soheilabadifard/TTL_to_3D/actions/workflows/tests.yml)
37
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
38
+ [![python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](pyproject.toml)
39
+
40
+ Turn any Turtle / RDF graph into a single self-contained HTML page with a 3D
41
+ view (shaded spheres) and a 2D view (a flat canvas), permanent labels, predicate
42
+ labels on the edges, a clickable legend, label search, and a detail card for
43
+ every node. The page embeds all of its JavaScript, so it opens from a file,
44
+ works offline, and never sends your data anywhere.
45
+
46
+ ![ttl3d rendering the Solar System demo ontology](docs/screenshot.png)
47
+
48
+ ## Why
49
+
50
+ Ontology editors show class trees. SPARQL browsers show one neighbourhood at a
51
+ time. Sometimes you want the whole graph on screen at once: to see which
52
+ modules actually connect, which file asserted which edge, or which node
53
+ everything hangs from. ttl3d was written for exactly that while building a
54
+ multi-file ontology, then generalized to any RDF input.
55
+
56
+ ## Install
57
+
58
+ Python 3.10 or newer.
59
+
60
+ ```bash
61
+ pip install git+https://github.com/soheilabadifard/TTL_to_3D
62
+ ```
63
+
64
+ Or clone the repository and run `pip install -e .`. The dependencies are
65
+ `rdflib`, `networkx`, `numpy` and `scipy`, all available on conda-forge as
66
+ well.
67
+
68
+ ## Quick start
69
+
70
+ ```bash
71
+ ttl3d examples/solar-system.ttl examples/solar-system-missions.ttl -o solar.html
72
+ ```
73
+
74
+ Open `solar.html` in any browser. Pass several files and they merge into one
75
+ graph; the default colouring then shows which file declared each node and
76
+ which file asserted each edge.
77
+
78
+ Without installing, run the module from a clone: `python -m ttl3d ...`.
79
+
80
+ ## Options
81
+
82
+ | Option | Values | Meaning |
83
+ |--------|--------|---------|
84
+ | `-o, --out` | path | Output file. Default: `<first file stem>-<view>.html` in the current directory. |
85
+ | `--color-by` | `file` (default), `type`, `namespace` | What node colours and the legend mean. In `file` mode edges also take the colour of the file asserting them. |
86
+ | `--layout` | `auto` (default), `stress`, `force` | `stress` pins every node to a precomputed Kamada-Kawai position, one layout per view (3D and 2D); `force` runs the live simulation. `auto` picks `stress` up to 1000 nodes. |
87
+ | `--labels` | `auto` (default), `always`, `hover` | Permanent label sprites. `auto` keeps node labels up to 800 nodes and edge labels up to 800 links; `hover` leaves tooltips only. |
88
+ | `--view` | `3d` (default), `2d` | Starting view. The page has a `3D \| 2D` switch either way, and each view is pinned to its own stress layout. |
89
+ | `--title` | text | Page title. Default: the first file's stem. |
90
+ | `--lang` | language tag, default `en` | Preferred language for labels and definitions. Untagged literals rank next; other languages become synonyms on the card. Matched exactly: `--lang en` does not select `@en-GB`. |
91
+ | `--format` | rdflib parser name | Force a parser for every input (`turtle`, `xml`, `nt`, `json-ld`, ...). Default: guess from the extension, then try Turtle. |
92
+ | `--type-links` | flag | Draw `rdf:type` as an edge from each instance to its class instead of listing it on the card only. |
93
+ | `--attribute-preds` | `prefix:local`, full IRI, or `<urn:...>` | Predicates to keep off the picture and show on the card, e.g. `foaf:homepage,rdfs:seeAlso`. Repeatable; wrap a URN or mailto in angle brackets. |
94
+ | `--version` | flag | Print `ttl3d <version>` and exit. |
95
+
96
+ Input formats are guessed from the extension (`.ttl`, `.nt`, `.n3`, `.rdf`,
97
+ `.owl`, `.jsonld`, ...) through rdflib.
98
+
99
+ ## What becomes a node, what becomes a link
100
+
101
+ - A node is every IRI that is the subject of some triple or the object of a
102
+ link, except IRIs of the RDF, RDFS, OWL and XSD vocabularies and ontology
103
+ headers (`owl:Ontology`). Blank nodes are skipped.
104
+ - A link is an IRI-to-IRI triple whose predicate is a relation. `rdf:type`,
105
+ `owl:imports`, `owl:versionIRI`, `owl:priorVersion`, `rdfs:isDefinedBy`,
106
+ `prov:wasDerivedFrom` and `dcterms:source` describe the node instead and go
107
+ to its card. Parallel edges collapse into one link listing every predicate.
108
+ - Label: `rdfs:label`, else `skos:prefLabel`, else the local name.
109
+ Definition: `skos:definition`, else `rdfs:comment`, else `dcterms:description`.
110
+ Every other literal lands in the card's property table.
111
+ - Each node remembers the file that first declares it; each link remembers the
112
+ files asserting it. A file that only adds edges between other files' nodes
113
+ still owns something visible.
114
+ - An edge asserted in both directions (`:Luna :orbits :Earth` and `:Earth :hasMoon :Luna`) is one link labelled `orbits ⇄ hasMoon`, without an arrowhead.
115
+ - Labels follow `--lang`: the requested language first, then untagged literals, then anything else; every other label value becomes a synonym on the card, and a `rdfs:comment` that loses to a `skos:definition` still appears in the property table.
116
+
117
+ ## In the page
118
+
119
+ - Legend rows filter inclusively: a selected group keeps its own nodes, their
120
+ direct neighbours, and the endpoints of every edge it asserts.
121
+ - The `3D | 2D` switch rebuilds the picture in the other view. Legend filters, the
122
+ search text and the open card carry over, and in pinned mode each view has its own
123
+ precomputed stress layout. A browser without WebGL opens in 2D and says so.
124
+ - Search dims everything whose label does not match.
125
+ - Click a node for its card: types, file, namespace, definition, synonyms,
126
+ properties, incoming and outgoing relations (clickable), and sources.
127
+ - The label toggles rebuild the scene, so a big graph can start without
128
+ sprites and switch them on later.
129
+ - "Free-float physics" releases pinned nodes into the live force layout and
130
+ pins them back where they were.
131
+ - No fog: white fog made distant nodes vanish on zoom-out.
132
+ - Past twelve groups only the eleven largest keep a colour; the rest share one grey "other" row that filters them together.
133
+
134
+ ## Scale
135
+
136
+ The stress layout is quadratic in the number of nodes and label sprites are
137
+ scene objects, so the automatic modes degrade instead of freezing:
138
+
139
+ | Graph size | Layout | Labels |
140
+ |-----------|--------|--------|
141
+ | up to 800 nodes / 800 links | pinned stress layout | nodes and edges |
142
+ | up to 1000 nodes | pinned stress layout | tooltips for the layer over its threshold |
143
+ | larger | live force simulation | tooltips; switch sprites on from the panel |
144
+
145
+ The stress layout is computed twice, once per view, so a pinned page takes about
146
+ twice the layout time of 0.1.0.
147
+
148
+ Force the behaviour you want with `--layout` and `--labels`.
149
+
150
+ ## Limitations
151
+
152
+ - Blank nodes are skipped, so OWL restrictions and RDF lists do not appear.
153
+ - No reasoning: only asserted triples are drawn.
154
+ - One page holds one graph; there is no incremental loading.
155
+ - The stress layout is quadratic: `--layout stress` on graphs far beyond 1000 nodes can take minutes, and the CLI says so on stderr.
156
+
157
+ ## Development
158
+
159
+ ```bash
160
+ pip install -e .[test]
161
+ python -m pytest -q
162
+ ```
163
+
164
+ The tests cover the loader, the node and link rules, the layout, the generated page, the vendored
165
+ bundle's pins and licence notices, and the command line. The viewer's CSS and JavaScript live in `ttl3d/viewer.css`, `ttl3d/viewer.js` (shared by both views), `ttl3d/viewer-3d.js` and `ttl3d/viewer-2d.js` (one renderer per view)
166
+ and are inlined into every page; when `node` is installed the suite syntax-checks the
167
+ JavaScript. `pip install -e .[browser] && playwright install chromium` enables the headless
168
+ browser tests that load the demo pages, switch views and assert zero console errors
169
+ (`TTL3D_BROWSER=firefox` or `webkit` picks another engine). CI runs the suite on Linux, Windows
170
+ and macOS, the browser tests on Chromium, Firefox and WebKit, and checks that the demo pages
171
+ built on the three systems are identical apart from the pinned layout's last digits. The vendored JavaScript
172
+ in `ttl3d/vendor/` bundles 3d-force-graph, force-graph, three-spritetext and one shared three.js;
173
+ `VENDOR.md` there has the rebuild recipe and `LICENSES.md` the upstream notices.
174
+
175
+ ## License
176
+
177
+ MIT. The vendored JavaScript is under permissive licences (MIT, ISC and others); `ttl3d/vendor/LICENSES.md` lists every bundled package with its licence and copyright line.
ttl3d-0.2.2/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # ttl3d
2
+
3
+ [![tests](https://github.com/soheilabadifard/TTL_to_3D/actions/workflows/tests.yml/badge.svg)](https://github.com/soheilabadifard/TTL_to_3D/actions/workflows/tests.yml)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](pyproject.toml)
6
+
7
+ Turn any Turtle / RDF graph into a single self-contained HTML page with a 3D
8
+ view (shaded spheres) and a 2D view (a flat canvas), permanent labels, predicate
9
+ labels on the edges, a clickable legend, label search, and a detail card for
10
+ every node. The page embeds all of its JavaScript, so it opens from a file,
11
+ works offline, and never sends your data anywhere.
12
+
13
+ ![ttl3d rendering the Solar System demo ontology](docs/screenshot.png)
14
+
15
+ ## Why
16
+
17
+ Ontology editors show class trees. SPARQL browsers show one neighbourhood at a
18
+ time. Sometimes you want the whole graph on screen at once: to see which
19
+ modules actually connect, which file asserted which edge, or which node
20
+ everything hangs from. ttl3d was written for exactly that while building a
21
+ multi-file ontology, then generalized to any RDF input.
22
+
23
+ ## Install
24
+
25
+ Python 3.10 or newer.
26
+
27
+ ```bash
28
+ pip install git+https://github.com/soheilabadifard/TTL_to_3D
29
+ ```
30
+
31
+ Or clone the repository and run `pip install -e .`. The dependencies are
32
+ `rdflib`, `networkx`, `numpy` and `scipy`, all available on conda-forge as
33
+ well.
34
+
35
+ ## Quick start
36
+
37
+ ```bash
38
+ ttl3d examples/solar-system.ttl examples/solar-system-missions.ttl -o solar.html
39
+ ```
40
+
41
+ Open `solar.html` in any browser. Pass several files and they merge into one
42
+ graph; the default colouring then shows which file declared each node and
43
+ which file asserted each edge.
44
+
45
+ Without installing, run the module from a clone: `python -m ttl3d ...`.
46
+
47
+ ## Options
48
+
49
+ | Option | Values | Meaning |
50
+ |--------|--------|---------|
51
+ | `-o, --out` | path | Output file. Default: `<first file stem>-<view>.html` in the current directory. |
52
+ | `--color-by` | `file` (default), `type`, `namespace` | What node colours and the legend mean. In `file` mode edges also take the colour of the file asserting them. |
53
+ | `--layout` | `auto` (default), `stress`, `force` | `stress` pins every node to a precomputed Kamada-Kawai position, one layout per view (3D and 2D); `force` runs the live simulation. `auto` picks `stress` up to 1000 nodes. |
54
+ | `--labels` | `auto` (default), `always`, `hover` | Permanent label sprites. `auto` keeps node labels up to 800 nodes and edge labels up to 800 links; `hover` leaves tooltips only. |
55
+ | `--view` | `3d` (default), `2d` | Starting view. The page has a `3D \| 2D` switch either way, and each view is pinned to its own stress layout. |
56
+ | `--title` | text | Page title. Default: the first file's stem. |
57
+ | `--lang` | language tag, default `en` | Preferred language for labels and definitions. Untagged literals rank next; other languages become synonyms on the card. Matched exactly: `--lang en` does not select `@en-GB`. |
58
+ | `--format` | rdflib parser name | Force a parser for every input (`turtle`, `xml`, `nt`, `json-ld`, ...). Default: guess from the extension, then try Turtle. |
59
+ | `--type-links` | flag | Draw `rdf:type` as an edge from each instance to its class instead of listing it on the card only. |
60
+ | `--attribute-preds` | `prefix:local`, full IRI, or `<urn:...>` | Predicates to keep off the picture and show on the card, e.g. `foaf:homepage,rdfs:seeAlso`. Repeatable; wrap a URN or mailto in angle brackets. |
61
+ | `--version` | flag | Print `ttl3d <version>` and exit. |
62
+
63
+ Input formats are guessed from the extension (`.ttl`, `.nt`, `.n3`, `.rdf`,
64
+ `.owl`, `.jsonld`, ...) through rdflib.
65
+
66
+ ## What becomes a node, what becomes a link
67
+
68
+ - A node is every IRI that is the subject of some triple or the object of a
69
+ link, except IRIs of the RDF, RDFS, OWL and XSD vocabularies and ontology
70
+ headers (`owl:Ontology`). Blank nodes are skipped.
71
+ - A link is an IRI-to-IRI triple whose predicate is a relation. `rdf:type`,
72
+ `owl:imports`, `owl:versionIRI`, `owl:priorVersion`, `rdfs:isDefinedBy`,
73
+ `prov:wasDerivedFrom` and `dcterms:source` describe the node instead and go
74
+ to its card. Parallel edges collapse into one link listing every predicate.
75
+ - Label: `rdfs:label`, else `skos:prefLabel`, else the local name.
76
+ Definition: `skos:definition`, else `rdfs:comment`, else `dcterms:description`.
77
+ Every other literal lands in the card's property table.
78
+ - Each node remembers the file that first declares it; each link remembers the
79
+ files asserting it. A file that only adds edges between other files' nodes
80
+ still owns something visible.
81
+ - An edge asserted in both directions (`:Luna :orbits :Earth` and `:Earth :hasMoon :Luna`) is one link labelled `orbits ⇄ hasMoon`, without an arrowhead.
82
+ - Labels follow `--lang`: the requested language first, then untagged literals, then anything else; every other label value becomes a synonym on the card, and a `rdfs:comment` that loses to a `skos:definition` still appears in the property table.
83
+
84
+ ## In the page
85
+
86
+ - Legend rows filter inclusively: a selected group keeps its own nodes, their
87
+ direct neighbours, and the endpoints of every edge it asserts.
88
+ - The `3D | 2D` switch rebuilds the picture in the other view. Legend filters, the
89
+ search text and the open card carry over, and in pinned mode each view has its own
90
+ precomputed stress layout. A browser without WebGL opens in 2D and says so.
91
+ - Search dims everything whose label does not match.
92
+ - Click a node for its card: types, file, namespace, definition, synonyms,
93
+ properties, incoming and outgoing relations (clickable), and sources.
94
+ - The label toggles rebuild the scene, so a big graph can start without
95
+ sprites and switch them on later.
96
+ - "Free-float physics" releases pinned nodes into the live force layout and
97
+ pins them back where they were.
98
+ - No fog: white fog made distant nodes vanish on zoom-out.
99
+ - Past twelve groups only the eleven largest keep a colour; the rest share one grey "other" row that filters them together.
100
+
101
+ ## Scale
102
+
103
+ The stress layout is quadratic in the number of nodes and label sprites are
104
+ scene objects, so the automatic modes degrade instead of freezing:
105
+
106
+ | Graph size | Layout | Labels |
107
+ |-----------|--------|--------|
108
+ | up to 800 nodes / 800 links | pinned stress layout | nodes and edges |
109
+ | up to 1000 nodes | pinned stress layout | tooltips for the layer over its threshold |
110
+ | larger | live force simulation | tooltips; switch sprites on from the panel |
111
+
112
+ The stress layout is computed twice, once per view, so a pinned page takes about
113
+ twice the layout time of 0.1.0.
114
+
115
+ Force the behaviour you want with `--layout` and `--labels`.
116
+
117
+ ## Limitations
118
+
119
+ - Blank nodes are skipped, so OWL restrictions and RDF lists do not appear.
120
+ - No reasoning: only asserted triples are drawn.
121
+ - One page holds one graph; there is no incremental loading.
122
+ - The stress layout is quadratic: `--layout stress` on graphs far beyond 1000 nodes can take minutes, and the CLI says so on stderr.
123
+
124
+ ## Development
125
+
126
+ ```bash
127
+ pip install -e .[test]
128
+ python -m pytest -q
129
+ ```
130
+
131
+ The tests cover the loader, the node and link rules, the layout, the generated page, the vendored
132
+ bundle's pins and licence notices, and the command line. The viewer's CSS and JavaScript live in `ttl3d/viewer.css`, `ttl3d/viewer.js` (shared by both views), `ttl3d/viewer-3d.js` and `ttl3d/viewer-2d.js` (one renderer per view)
133
+ and are inlined into every page; when `node` is installed the suite syntax-checks the
134
+ JavaScript. `pip install -e .[browser] && playwright install chromium` enables the headless
135
+ browser tests that load the demo pages, switch views and assert zero console errors
136
+ (`TTL3D_BROWSER=firefox` or `webkit` picks another engine). CI runs the suite on Linux, Windows
137
+ and macOS, the browser tests on Chromium, Firefox and WebKit, and checks that the demo pages
138
+ built on the three systems are identical apart from the pinned layout's last digits. The vendored JavaScript
139
+ in `ttl3d/vendor/` bundles 3d-force-graph, force-graph, three-spritetext and one shared three.js;
140
+ `VENDOR.md` there has the rebuild recipe and `LICENSES.md` the upstream notices.
141
+
142
+ ## License
143
+
144
+ MIT. The vendored JavaScript is under permissive licences (MIT, ISC and others); `ttl3d/vendor/LICENSES.md` lists every bundled package with its licence and copyright line.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ttl3d"
7
+ dynamic = ["version"]
8
+ description = "Turn any Turtle / RDF graph into a self-contained, offline 2D/3D HTML viewer"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ authors = [{name = "Soheil Abadifard"}]
12
+ requires-python = ">=3.10"
13
+ dependencies = ["rdflib>=7", "networkx>=3", "numpy", "scipy"]
14
+ keywords = ["rdf", "turtle", "owl", "ontology", "knowledge-graph", "semantic-web",
15
+ "linked-data", "visualization", "3d", "2d", "graph-visualization"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Science/Research",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Scientific/Engineering :: Visualization",
27
+ ]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/soheilabadifard/TTL_to_3D"
31
+ Issues = "https://github.com/soheilabadifard/TTL_to_3D/issues"
32
+
33
+ [project.scripts]
34
+ ttl3d = "ttl3d.cli:main"
35
+
36
+ [project.optional-dependencies]
37
+ test = ["pytest"]
38
+ browser = ["pytest", "playwright"]
39
+
40
+ [tool.setuptools.packages.find]
41
+ include = ["ttl3d*"]
42
+
43
+ [tool.setuptools.package-data]
44
+ ttl3d = ["vendor/*.js", "vendor/*.md", "viewer.js", "viewer-3d.js", "viewer-2d.js", "viewer.css"]
45
+
46
+ [tool.setuptools.dynamic]
47
+ version = {attr = "ttl3d.__version__"}
48
+
49
+ [tool.pytest.ini_options]
50
+ testpaths = ["tests"]
51
+
52
+ [tool.ruff]
53
+ line-length = 110
ttl3d-0.2.2/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,35 @@
1
+ """The vendored bundle is the only JavaScript the page loads; it must carry all four globals,
2
+ and its rebuild must be reproducible from the pins next to it."""
3
+ import json
4
+ from pathlib import Path
5
+
6
+ VENDOR = Path(__file__).resolve().parents[1] / "ttl3d" / "vendor" / "fg-bundle.min.js"
7
+
8
+
9
+ def test_bundle_exposes_required_globals():
10
+ lib = VENDOR.read_text(encoding="utf-8")
11
+ # `window.ForceGraph=` with the `=` so `ForceGraph3D` cannot stand in for the 2D library
12
+ for g in ("ForceGraph3D", "ForceGraph", "SpriteText", "THREE"):
13
+ assert f"window.{g}=" in lib, g
14
+ assert "</script" not in lib and "sourceMappingURL" not in lib
15
+
16
+
17
+ def test_vendor_pins_are_exact_and_locked():
18
+ pins = json.loads((VENDOR.parent / "package.json").read_text(encoding="utf-8"))
19
+ for name, ver in {**pins["dependencies"], **pins["devDependencies"]}.items():
20
+ assert ver[0].isdigit(), f"{name} is not pinned exactly: {ver}"
21
+ assert pins["dependencies"]["force-graph"] == "1.51.4" and pins["dependencies"]["three"] == "0.185.1"
22
+ assert (VENDOR.parent / "package-lock.json").exists()
23
+
24
+
25
+ def test_vendor_notices_cover_every_runtime_package_in_the_lockfile():
26
+ # MIT and ISC both require the copyright notice to travel with copies; the bundle
27
+ # inlines every runtime package of the lockfile, so every one needs a line here
28
+ lock = json.loads((VENDOR.parent / "package-lock.json").read_text(encoding="utf-8"))
29
+ notices = (VENDOR.parent / "LICENSES.md").read_text(encoding="utf-8")
30
+ runtime = {(path.split("node_modules/")[-1], meta["version"])
31
+ for path, meta in lock["packages"].items() if path and not meta.get("dev")}
32
+ missing = sorted(f"{name} {version}" for name, version in runtime
33
+ if f"- {name} {version} " not in notices)
34
+ assert not missing, missing
35
+ assert "## The MIT License" in notices and "## The ISC License" in notices
@@ -0,0 +1,227 @@
1
+ """ttl3d.cli: the command line entry point."""
2
+ import json
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from ttl3d import cli
11
+
12
+ REPO = Path(__file__).resolve().parents[1]
13
+
14
+
15
+ def test_cli_writes_the_page_and_reports_what_it_did(tmp_path, library, library_extra, capsys):
16
+ out = tmp_path / "lib.html"
17
+ rc = cli.main([str(library), str(library_extra), "-o", str(out), "--title", "Lib"])
18
+ assert rc == 0
19
+ html = out.read_text(encoding="utf-8")
20
+ assert "<title>Lib</title>" in html and '"pinned": true' in html
21
+ printed = capsys.readouterr().out
22
+ assert "12 nodes" in printed and "6 links" in printed and "stress" in printed
23
+
24
+
25
+ def test_cli_default_output_is_named_after_the_first_file(tmp_path, library, monkeypatch):
26
+ monkeypatch.chdir(tmp_path)
27
+ assert cli.main([str(library)]) == 0
28
+ assert (tmp_path / "library-3d.html").exists()
29
+
30
+
31
+ def test_cli_color_by_type_puts_types_in_the_legend(tmp_path, library):
32
+ out = tmp_path / "t.html"
33
+ cli.main([str(library), "-o", str(out), "--color-by", "type"])
34
+ assert 'data-group="Book"' in out.read_text(encoding="utf-8")
35
+
36
+
37
+ def test_cli_force_layout_leaves_nodes_unpinned(tmp_path, library):
38
+ out = tmp_path / "f.html"
39
+ cli.main([str(library), "-o", str(out), "--layout", "force"])
40
+ assert '"pinned": false' in out.read_text(encoding="utf-8")
41
+
42
+
43
+ def test_cli_hover_labels_disable_both_label_layers(tmp_path, library):
44
+ out = tmp_path / "h.html"
45
+ cli.main([str(library), "-o", str(out), "--labels", "hover"])
46
+ assert '"node": false' in out.read_text(encoding="utf-8") and '"edge": false' in out.read_text(encoding="utf-8")
47
+
48
+
49
+ def test_cli_rejects_an_unknown_color_key(library):
50
+ with pytest.raises(SystemExit) as e:
51
+ cli.main([str(library), "--color-by", "colour"])
52
+ assert e.value.code == 2
53
+
54
+
55
+ def test_module_entry_point_runs(tmp_path, library):
56
+ out = tmp_path / "m.html"
57
+ r = subprocess.run([sys.executable, "-m", "ttl3d", str(library), "-o", str(out)],
58
+ capture_output=True, text=True, cwd=REPO, check=False)
59
+ assert r.returncode == 0, r.stderr
60
+ assert out.exists()
61
+
62
+
63
+ def test_cli_missing_file_is_a_clean_error(tmp_path, capsys):
64
+ out = tmp_path / "x.html"
65
+ rc = cli.main([str(tmp_path / "nope.ttl"), "-o", str(out)])
66
+ assert rc == 1
67
+ err = capsys.readouterr().err
68
+ assert err.startswith("ttl3d: error:") and "nope.ttl" in err
69
+ assert not out.exists()
70
+
71
+
72
+ def test_cli_malformed_file_is_a_clean_error(tmp_path, capsys):
73
+ bad = tmp_path / "bad.ttl"
74
+ bad.write_text("this is not turtle @@@\n", encoding="utf-8")
75
+ rc = cli.main([str(bad), "-o", str(tmp_path / "x.html")])
76
+ assert rc == 1
77
+ err = capsys.readouterr().err
78
+ assert err.startswith("ttl3d: error:") and "bad.ttl" in err
79
+ assert err.count("\n") == 1
80
+
81
+
82
+ def test_cli_refuses_to_overwrite_an_input(tmp_path, library, capsys):
83
+ victim = tmp_path / "victim.ttl"
84
+ victim.write_text(library.read_text(encoding="utf-8"), encoding="utf-8")
85
+ rc = cli.main([str(victim), "-o", str(victim)])
86
+ assert rc == 1
87
+ assert victim.read_text(encoding="utf-8") == library.read_text(encoding="utf-8")
88
+ assert "input" in capsys.readouterr().err
89
+
90
+
91
+ def test_cli_output_directory_is_a_clean_error(tmp_path, library, capsys):
92
+ rc = cli.main([str(library), "-o", str(tmp_path)])
93
+ assert rc == 1
94
+ assert capsys.readouterr().err.startswith("ttl3d: error:")
95
+
96
+
97
+ def test_cli_header_only_file_produces_an_empty_page(tmp_path, capsys):
98
+ ttl = tmp_path / "header.ttl"
99
+ ttl.write_text("@prefix owl: <http://www.w3.org/2002/07/owl#> .\n"
100
+ "<http://example.org/o> a owl:Ontology .\n", encoding="utf-8")
101
+ out = tmp_path / "header.html"
102
+ assert cli.main([str(ttl), "-o", str(out)]) == 0
103
+ assert "0 nodes" in capsys.readouterr().out and out.exists()
104
+
105
+
106
+ def test_cli_announces_a_big_stress_layout_on_stderr(tmp_path, capsys):
107
+ from ttl3d import layout
108
+ n = layout.PROGRESS_MIN_NODES + 1
109
+ ttl = tmp_path / "big.ttl"
110
+ ttl.write_text("@prefix ex: <http://example.org/b#> .\n"
111
+ + "".join(f"ex:n{i} ex:p ex:n{i + 1} .\n" for i in range(n)), encoding="utf-8")
112
+ assert cli.main([str(ttl), "-o", str(tmp_path / "big.html")]) == 0
113
+ assert "stress layout" in capsys.readouterr().err
114
+
115
+
116
+ @pytest.mark.parametrize("view", ["3d", "2d"])
117
+ def test_cli_output_is_identical_across_processes(tmp_path, view):
118
+ pages = []
119
+ for seed in ("1", "2"):
120
+ out = tmp_path / f"solar-{seed}.html"
121
+ r = subprocess.run([sys.executable, "-m", "ttl3d",
122
+ str(REPO / "examples" / "solar-system.ttl"),
123
+ str(REPO / "examples" / "solar-system-missions.ttl"),
124
+ "-o", str(out), "--view", view],
125
+ capture_output=True, text=True, cwd=REPO, check=False,
126
+ env={**os.environ, "PYTHONHASHSEED": seed})
127
+ assert r.returncode == 0, r.stderr
128
+ pages.append(out.read_bytes())
129
+ assert pages[0] == pages[1]
130
+
131
+
132
+ def test_cli_lang_picks_the_label_language(tmp_path):
133
+ ttl = tmp_path / "lang.ttl"
134
+ ttl.write_text("@prefix ex: <http://example.org/l#> .\n"
135
+ "@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n"
136
+ 'ex:z rdfs:label "Zebra"@en, "Antilope"@de ; ex:p ex:b .\n', encoding="utf-8")
137
+ out = tmp_path / "l.html"
138
+ cli.main([str(ttl), "-o", str(out)])
139
+ assert '"label": "Zebra"' in out.read_text(encoding="utf-8")
140
+ cli.main([str(ttl), "-o", str(out), "--lang", "de"])
141
+ assert '"label": "Antilope"' in out.read_text(encoding="utf-8")
142
+
143
+
144
+ def test_cli_format_flag(tmp_path, library):
145
+ weird = tmp_path / "data.txt"
146
+ weird.write_text(library.read_text(encoding="utf-8"), encoding="utf-8")
147
+ out = tmp_path / "f.html"
148
+ assert cli.main([str(weird), "-o", str(out), "--format", "xml"]) == 1 # forced parser rejects Turtle
149
+ assert cli.main([str(weird), "-o", str(out), "--format", "turtle"]) == 0 and out.exists()
150
+
151
+
152
+ def test_cli_type_links_and_attribute_preds(tmp_path, library):
153
+ out = tmp_path / "t.html"
154
+ cli.main([str(library), "-o", str(out), "--type-links", "--attribute-preds", "ex:wrote,rdfs:subClassOf"])
155
+ payload = out.read_text(encoding="utf-8").split("const DATA = ", 1)[1].split(";\n", 1)[0]
156
+ assert '"predicates": ["type"]' in payload # Herbert -> Author is now drawn
157
+ assert '"predicates": ["wrote"]' not in payload # ex:wrote is demoted to the card
158
+ assert '"predicates": ["subClassOf"]' not in payload
159
+
160
+
161
+ def test_cli_unknown_prefix_in_attribute_preds_is_a_clean_error(tmp_path, library, capsys):
162
+ rc = cli.main([str(library), "-o", str(tmp_path / "x.html"), "--attribute-preds", "nope:thing"])
163
+ assert rc == 1 and "nope" in capsys.readouterr().err
164
+
165
+
166
+ def test_cli_pages_start_in_3d(tmp_path, library):
167
+ out = tmp_path / "d.html"
168
+ assert cli.main([str(library), "-o", str(out)]) == 0
169
+ assert '"view": "3d"' in out.read_text(encoding="utf-8")
170
+
171
+
172
+ def _first_node(page_path):
173
+ payload = page_path.read_text(encoding="utf-8").split("const DATA = ", 1)[1].split(";\n", 1)[0]
174
+ return json.loads(payload)["nodes"][0]
175
+
176
+
177
+ def test_cli_pinned_pages_carry_a_layout_per_view(tmp_path, library):
178
+ out = tmp_path / "p.html"
179
+ assert cli.main([str(library), "-o", str(out)]) == 0
180
+ assert {"x", "y", "z", "x2", "y2"} <= set(_first_node(out))
181
+ assert cli.main([str(library), "-o", str(out), "--layout", "force"]) == 0
182
+ assert not {"x", "y", "z", "x2", "y2"} & set(_first_node(out))
183
+
184
+
185
+ def test_cli_view_flag_sets_the_starting_view_and_the_default_name(tmp_path, library, monkeypatch, capsys):
186
+ monkeypatch.chdir(tmp_path)
187
+ assert cli.main([str(library), "--view", "2d"]) == 0
188
+ assert '"view": "2d"' in (tmp_path / "library-2d.html").read_text(encoding="utf-8")
189
+ assert "view: 2d" in capsys.readouterr().out
190
+
191
+
192
+ def test_cli_rejects_an_unknown_view(library):
193
+ with pytest.raises(SystemExit) as e:
194
+ cli.main([str(library), "--view", "4d"])
195
+ assert e.value.code == 2
196
+
197
+
198
+ def test_output_naming_an_input_in_another_case_is_refused_where_the_filesystem_ignores_case(tmp_path, library,
199
+ capsys):
200
+ (tmp_path / "probe").write_text("", encoding="utf-8")
201
+ if not (tmp_path / "PROBE").exists():
202
+ pytest.skip("case-sensitive filesystem")
203
+ src = tmp_path / "graph.ttl"
204
+ src.write_text(library.read_text(encoding="utf-8"), encoding="utf-8")
205
+ rc = cli.main([str(src), "-o", str(tmp_path / "GRAPH.TTL")])
206
+ assert rc == 1 and "also an input" in capsys.readouterr().err
207
+ assert src.read_text(encoding="utf-8") == library.read_text(encoding="utf-8") # the input is intact
208
+
209
+
210
+ ASCII_CONSOLE = {**os.environ, "PYTHONIOENCODING": "ascii"} # a console that cannot show an é
211
+
212
+
213
+ def test_an_error_message_survives_a_console_that_cannot_encode_it(tmp_path):
214
+ bad = tmp_path / "données.ttl"
215
+ bad.write_text("@prefix ex: <http://example.org/é#> .\nex:a ex:b \"unterminated .\n", encoding="utf-8")
216
+ r = subprocess.run([sys.executable, "-m", "ttl3d", str(bad), "-o", str(tmp_path / "out.html")],
217
+ capture_output=True, env=ASCII_CONSOLE, cwd=REPO, check=False)
218
+ err = r.stderr.decode("ascii")
219
+ assert r.returncode == 1 and err.startswith("ttl3d: error:") and "Traceback" not in err, err
220
+
221
+
222
+ def test_the_summary_line_survives_a_console_that_cannot_encode_the_output_path(tmp_path, library):
223
+ out = tmp_path / "sortie-é.html"
224
+ r = subprocess.run([sys.executable, "-m", "ttl3d", str(library), "-o", str(out)],
225
+ capture_output=True, env=ASCII_CONSOLE, cwd=REPO, check=False)
226
+ assert r.returncode == 0 and out.exists(), r.stderr.decode("ascii", "replace")
227
+ assert r.stdout.decode("ascii").split()[0].isdigit()