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 +21 -0
- ttl3d-0.2.2/PKG-INFO +177 -0
- ttl3d-0.2.2/README.md +144 -0
- ttl3d-0.2.2/pyproject.toml +53 -0
- ttl3d-0.2.2/setup.cfg +4 -0
- ttl3d-0.2.2/tests/test_bundle.py +35 -0
- ttl3d-0.2.2/tests/test_cli.py +227 -0
- ttl3d-0.2.2/tests/test_compare_pages.py +55 -0
- ttl3d-0.2.2/tests/test_graph.py +278 -0
- ttl3d-0.2.2/tests/test_layout.py +164 -0
- ttl3d-0.2.2/tests/test_load.py +66 -0
- ttl3d-0.2.2/tests/test_render.py +245 -0
- ttl3d-0.2.2/tests/test_viewer.py +373 -0
- ttl3d-0.2.2/ttl3d/__init__.py +2 -0
- ttl3d-0.2.2/ttl3d/__main__.py +3 -0
- ttl3d-0.2.2/ttl3d/cli.py +92 -0
- ttl3d-0.2.2/ttl3d/graph.py +196 -0
- ttl3d-0.2.2/ttl3d/layout.py +108 -0
- ttl3d-0.2.2/ttl3d/load.py +70 -0
- ttl3d-0.2.2/ttl3d/render.py +170 -0
- ttl3d-0.2.2/ttl3d/vendor/LICENSES.md +119 -0
- ttl3d-0.2.2/ttl3d/vendor/VENDOR.md +70 -0
- ttl3d-0.2.2/ttl3d/vendor/fg-bundle.min.js +5320 -0
- ttl3d-0.2.2/ttl3d/viewer-2d.js +79 -0
- ttl3d-0.2.2/ttl3d/viewer-3d.js +102 -0
- ttl3d-0.2.2/ttl3d/viewer.css +55 -0
- ttl3d-0.2.2/ttl3d/viewer.js +269 -0
- ttl3d-0.2.2/ttl3d.egg-info/PKG-INFO +177 -0
- ttl3d-0.2.2/ttl3d.egg-info/SOURCES.txt +31 -0
- ttl3d-0.2.2/ttl3d.egg-info/dependency_links.txt +1 -0
- ttl3d-0.2.2/ttl3d.egg-info/entry_points.txt +2 -0
- ttl3d-0.2.2/ttl3d.egg-info/requires.txt +11 -0
- ttl3d-0.2.2/ttl3d.egg-info/top_level.txt +1 -0
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
|
+
[](https://github.com/soheilabadifard/TTL_to_3D/actions/workflows/tests.yml)
|
|
37
|
+
[](LICENSE)
|
|
38
|
+
[](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
|
+

|
|
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
|
+
[](https://github.com/soheilabadifard/TTL_to_3D/actions/workflows/tests.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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
|
+

|
|
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,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()
|