linked-data-python 0.5.2__tar.gz → 0.6.1__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.
Files changed (71) hide show
  1. {linked_data_python-0.5.2/linked_data_python.egg-info → linked_data_python-0.6.1}/PKG-INFO +1 -1
  2. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/__main__.py +4 -1
  3. linked_data_python-0.6.1/ldpy/backend.py +90 -0
  4. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/build.py +41 -6
  5. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/runtime.py +177 -53
  6. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/sparql.py +4 -4
  7. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/transpiler/core.py +20 -3
  8. {linked_data_python-0.5.2 → linked_data_python-0.6.1/linked_data_python.egg-info}/PKG-INFO +1 -1
  9. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/linked_data_python.egg-info/SOURCES.txt +3 -1
  10. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/pyproject.toml +1 -1
  11. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_docs.py +21 -1
  12. linked_data_python-0.6.1/tests/test_target_micropython.py +134 -0
  13. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/LICENSE.md +0 -0
  14. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/README.md +0 -0
  15. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/__init__.py +0 -0
  16. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/console.py +0 -0
  17. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/debug.py +0 -0
  18. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/formatter.py +0 -0
  19. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/importer.py +0 -0
  20. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/__init__.py +0 -0
  21. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/__main__.py +0 -0
  22. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/backend.py +0 -0
  23. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/hover.py +0 -0
  24. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/islanddoc.py +0 -0
  25. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/rpc.py +0 -0
  26. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/server.py +0 -0
  27. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/lsp/translate.py +0 -0
  28. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/pygments_lexer.py +0 -0
  29. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/transpiler/__init__.py +0 -0
  30. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/transpiler/errors.py +0 -0
  31. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/ldpy/transpiler/linemap.py +0 -0
  32. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/linked_data_python.egg-info/dependency_links.txt +0 -0
  33. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/linked_data_python.egg-info/entry_points.txt +0 -0
  34. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/linked_data_python.egg-info/requires.txt +0 -0
  35. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/linked_data_python.egg-info/top_level.txt +0 -0
  36. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/setup.cfg +0 -0
  37. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_bench.py +0 -0
  38. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_bindings.py +0 -0
  39. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_build.py +0 -0
  40. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_call_suffix.py +0 -0
  41. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_charsets.py +0 -0
  42. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_coercion.py +0 -0
  43. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_console.py +0 -0
  44. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_current_graph.py +0 -0
  45. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_debug.py +0 -0
  46. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_debug_stepping.py +0 -0
  47. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_disambiguation.py +0 -0
  48. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_docstrings.py +0 -0
  49. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_examples.py +0 -0
  50. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_formatter.py +0 -0
  51. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_generator.py +0 -0
  52. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_graphs.py +0 -0
  53. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_hover_parts.py +0 -0
  54. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_identity.py +0 -0
  55. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_import_hook.py +0 -0
  56. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_islanddoc.py +0 -0
  57. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_islands_terms.py +0 -0
  58. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_linemap.py +0 -0
  59. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_lsp_rpc.py +0 -0
  60. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_lsp_server.py +0 -0
  61. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_lsp_translate.py +0 -0
  62. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_match_island.py +0 -0
  63. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_micropython_subset.py +0 -0
  64. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_prefix_import.py +0 -0
  65. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_prefix_scoping.py +0 -0
  66. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_pygments_lexer.py +0 -0
  67. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_scope_modifiers.py +0 -0
  68. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_semantics.py +0 -0
  69. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_sourcemap_v3.py +0 -0
  70. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_sparql_expr.py +0 -0
  71. {linked_data_python-0.5.2 → linked_data_python-0.6.1}/tests/test_sparql_island.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linked-data-python
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Python extended with Semantic Web primitives: IRIs, RDF literals and Turtle-notation graphs as expressions, transpiled by island parsing.
5
5
  Author-email: Maxime Lefrançois <maxime.lefrancois@emse.fr>
6
6
  License: MIT
@@ -30,6 +30,9 @@ def main(argv=None):
30
30
  help="open the interactive console after the script.")
31
31
  parser.add_argument("-m", "--map", action="store_true",
32
32
  help="also write the language map (<source>.map).")
33
+ parser.add_argument("--target", choices=["micropython"], default=None,
34
+ help="transpile for a device: s{ } is refused here, "
35
+ "at build time, where the message can be read.")
33
36
  parser.add_argument("source", nargs="?",
34
37
  help=".ldpy (or .py) file to run.")
35
38
  args = parser.parse_args(argv)
@@ -45,7 +48,7 @@ def main(argv=None):
45
48
  with open(args.source, "r", encoding="utf-8") as f:
46
49
  source = f.read()
47
50
  try:
48
- result = transpile(source, args.source)
51
+ result = transpile(source, args.source, target=args.target)
49
52
  except LdpySyntaxError as e:
50
53
  print(str(e), file=sys.stderr)
51
54
  return 1
@@ -0,0 +1,90 @@
1
+ """The RDF backend of the façade: rdflib on CPython, urdflib on MicroPython.
2
+
3
+ The generated code never touches rdflib: it goes through `ldpy.runtime`,
4
+ and `ldpy.runtime` takes its terms and graphs from HERE. So this module is
5
+ the one place that knows which library is underneath — and the contract a
6
+ backend must meet is exactly its public names (record urdflib/306 measures
7
+ them with `urdflib/tools/api_gap.py`).
8
+
9
+ Two backends:
10
+
11
+ * **rdflib**, whenever it imports — the default on CPython, and the only one
12
+ with a SPARQL engine (`s{ }`);
13
+ * **urdflib**, the MicroPython C module of the linked-data-python
14
+ organisation, when rdflib is absent. It has the terms and the graphs;
15
+ `Namespace`, `RDF` and `XSD` are pure Python and provided here.
16
+
17
+ The two are not exclusive options of the same kind: the backend is chosen
18
+ by what is importable, and SPARQL queries are a capability of one backend.
19
+ `e{ }` expressions (ldpy.sparql) are pure Python over the terms, and run on
20
+ both.
21
+ """
22
+
23
+ try:
24
+ import rdflib as _lib
25
+ from rdflib import RDF, XSD, BNode, Literal, Namespace, Variable
26
+ from rdflib.term import Node
27
+ URIRef = _lib.URIRef
28
+ Graph = _lib.Graph
29
+ NAME = "rdflib"
30
+ HAS_SPARQL = True
31
+ HAS_NAMESPACE_MANAGER = True
32
+ except ImportError: # MicroPython
33
+ import urdflib as _lib
34
+ from urdflib import BNode, Literal, Node, URIRef, Variable, Graph
35
+ NAME = "urdflib"
36
+ HAS_SPARQL = False
37
+ HAS_NAMESPACE_MANAGER = False
38
+
39
+ class Namespace(str):
40
+ """A namespace IRI whose attributes and items are terms — rdflib's
41
+ Namespace, reduced to what the emitted code uses."""
42
+
43
+ def __getattr__(self, name):
44
+ if name.startswith("__"):
45
+ raise AttributeError(name)
46
+ return URIRef(self + name)
47
+
48
+ def __getitem__(self, name):
49
+ return URIRef(self + name)
50
+
51
+ def term(self, name):
52
+ return URIRef(self + name)
53
+
54
+ RDF = Namespace("http://www.w3.org/1999/02/22-rdf-syntax-ns#")
55
+ XSD = Namespace("http://www.w3.org/2001/XMLSchema#")
56
+
57
+
58
+ def new_graph(base=None, identifier=None):
59
+ """A graph of the backend. rdflib's takes a base, urdflib's does not
60
+ (a base is a lexical matter, resolved by the transpiler already)."""
61
+ if NAME == "rdflib":
62
+ return Graph(base=base, identifier=identifier)
63
+ return Graph(identifier=identifier) if identifier is not None else Graph()
64
+
65
+
66
+ def bind_namespaces(graph, namespaces):
67
+ """Give `graph` the serialisation prefixes of `namespaces` (a dict
68
+ prefix -> namespace IRI). On rdflib the runtime shares a cached
69
+ NamespaceManager instead (record ldpy/021); this is the plain path."""
70
+ for prefix, ns in namespaces.items():
71
+ graph.bind(prefix, str(ns))
72
+
73
+
74
+ def prepare_sparql(text, namespaces, update=False):
75
+ """A prepared SPARQL query or update — rdflib's engine, or an error
76
+ that says which backend is missing it."""
77
+ if not HAS_SPARQL:
78
+ raise RuntimeError(
79
+ "s{ } needs a SPARQL engine, and the %s backend has none: "
80
+ "transpile with target='micropython' to be told at build time, "
81
+ "and use m{ } and e{ } on the device" % NAME)
82
+ from rdflib.plugins.sparql import prepareQuery, prepareUpdate
83
+ return (prepareUpdate if update else prepareQuery)(
84
+ text, initNs=dict(namespaces))
85
+
86
+
87
+ __all__ = ["NAME", "HAS_SPARQL", "HAS_NAMESPACE_MANAGER",
88
+ "URIRef", "BNode", "Literal", "Variable", "Node", "Graph",
89
+ "Namespace", "RDF", "XSD",
90
+ "new_graph", "bind_namespaces", "prepare_sparql"]
@@ -16,7 +16,35 @@ from ldpy.transpiler import transpile, LdpySyntaxError
16
16
  DEFAULT_OUT = ".ldpy-build"
17
17
 
18
18
 
19
- def build_file(src_path, out_dir, rel=None):
19
+ #: What a device needs of ldpy at run time, and nothing else: the façade,
20
+ #: its backend, and the expressions of e{ }. The transpiler stays on the
21
+ #: host. `ldpy/__init__.py` is NOT copied — it imports the transpiler and
22
+ #: the import hook — and is replaced by the docstring below.
23
+ DEVICE_RUNTIME = ("runtime.py", "backend.py", "sparql.py")
24
+ DEVICE_INIT = ('"""ldpy runtime for a device: what `import ldpy.runtime as '
25
+ '_ldpy_` needs.\n\nWritten by `python -m ldpy.build --target '
26
+ 'micropython`; the transpiler is not here.\n"""\n')
27
+
28
+
29
+ def bundle_device_runtime(out_dir):
30
+ """Copy the device runtime into <out_dir>/ldpy/, so that the emitted
31
+ files and what they import travel together. Returns the paths."""
32
+ here = os.path.dirname(os.path.abspath(__file__))
33
+ dst = os.path.join(out_dir, "ldpy")
34
+ os.makedirs(dst, exist_ok=True)
35
+ written = []
36
+ with open(os.path.join(dst, "__init__.py"), "w", encoding="utf-8") as f:
37
+ f.write(DEVICE_INIT)
38
+ written.append(os.path.join(dst, "__init__.py"))
39
+ for name in DEVICE_RUNTIME:
40
+ with open(os.path.join(here, name), "rb") as fi, \
41
+ open(os.path.join(dst, name), "wb") as fo:
42
+ fo.write(fi.read())
43
+ written.append(os.path.join(dst, name))
44
+ return written
45
+
46
+
47
+ def build_file(src_path, out_dir, rel=None, target=None):
20
48
  """Transpile one file; returns (py_path, map_path, result)."""
21
49
  with open(src_path, "r", encoding="utf-8") as f:
22
50
  source = f.read()
@@ -24,7 +52,7 @@ def build_file(src_path, out_dir, rel=None):
24
52
  stem = rel[:-5] if rel.endswith(".ldpy") else rel
25
53
  py_path = os.path.join(out_dir, stem + ".py")
26
54
  map_path = os.path.join(out_dir, stem + ".ldpy.map")
27
- result = transpile(source, src_path)
55
+ result = transpile(source, src_path, target=target)
28
56
  result.map.generated_name = py_path
29
57
  os.makedirs(os.path.dirname(py_path) or ".", exist_ok=True)
30
58
  with open(py_path, "w", encoding="utf-8") as f:
@@ -37,7 +65,7 @@ def build_file(src_path, out_dir, rel=None):
37
65
  return py_path, map_path, result
38
66
 
39
67
 
40
- def build_tree(root, out_dir):
68
+ def build_tree(root, out_dir, target=None):
41
69
  """Recursively transpile every .ldpy under root. Plain .py files are
42
70
  copied as they are (a mixed package must stay importable)."""
43
71
  built, errors = [], []
@@ -49,7 +77,7 @@ def build_tree(root, out_dir):
49
77
  rel = os.path.relpath(src, root)
50
78
  if name.endswith(".ldpy"):
51
79
  try:
52
- built.append(build_file(src, out_dir, rel))
80
+ built.append(build_file(src, out_dir, rel, target))
53
81
  except LdpySyntaxError as e:
54
82
  errors.append(e)
55
83
  elif name.endswith(".py"):
@@ -68,16 +96,23 @@ def main(argv=None):
68
96
  parser.add_argument("source", help=".ldpy file or directory")
69
97
  parser.add_argument("-o", "--out", default=DEFAULT_OUT,
70
98
  help="output directory (default: %(default)s)")
99
+ parser.add_argument("--target", choices=["micropython"], default=None,
100
+ help="build for a device: s{ } is refused, and the "
101
+ "ldpy runtime is copied next to the output")
71
102
  args = parser.parse_args(argv)
72
103
 
104
+ if args.target == "micropython":
105
+ os.makedirs(args.out, exist_ok=True)
106
+ bundle_device_runtime(args.out)
73
107
  if os.path.isdir(args.source):
74
- built, errors = build_tree(args.source, args.out)
108
+ built, errors = build_tree(args.source, args.out, args.target)
75
109
  for e in errors:
76
110
  print(str(e), file=sys.stderr)
77
111
  print("%d file(s) transpiled into %s" % (len(built), args.out))
78
112
  return 1 if errors else 0
79
113
  try:
80
- py_path, _, result = build_file(args.source, args.out)
114
+ py_path, _, result = build_file(args.source, args.out,
115
+ target=args.target)
81
116
  except LdpySyntaxError as e:
82
117
  print(str(e), file=sys.stderr)
83
118
  return 1
@@ -1,35 +1,33 @@
1
1
  """Runtime Linked-Data Python v2.
2
2
 
3
3
  The code generated by the transpiler imports this module under the reserved
4
- alias `_ldpy_` and uses ONLY this facade — never rdflib directly. rdflib is
5
- the default backend; the facade allows an alternative one (urdflib / a
6
- MicroPython implementation) without changing the generated code.
4
+ alias `_ldpy_` and uses ONLY this facade — never an RDF library directly.
5
+ The library underneath is chosen by `ldpy.backend`: rdflib on the host,
6
+ urdflib on a MicroPython device. The generated code is the same on both.
7
7
 
8
8
  Emission and runtime choices are explained in docs/explanation/.
9
9
  """
10
10
 
11
- import itertools
12
-
13
- import rdflib
14
- from rdflib import RDF, BNode, Literal, Variable, Namespace
15
- from rdflib.term import Node
11
+ from ldpy import backend as _backend
12
+ from ldpy.backend import RDF, BNode, Literal, Variable, Namespace, Node
13
+ from ldpy.backend import URIRef as _URIRef, Graph as _Graph
16
14
 
17
15
  _URI_CACHE = {}
18
16
 
19
17
 
20
18
  def URIRef(value, base=None):
21
- """rdflib.URIRef with a cache (key = the string). The IRIs emitted by the
22
- transpiler are CONSTANTS of the program: without a cache, every turn of a
23
- loop over a g{...} rebuilt them (see OPTIMIZATION.md). The cache is
24
- bounded by the text of the programs; a 1M-entry guard covers dynamic uses
25
- through the API."""
19
+ """The backend's URIRef with a cache (key = the string). The IRIs emitted
20
+ by the transpiler are CONSTANTS of the program: without a cache, every
21
+ turn of a loop over a g{...} rebuilt them (see OPTIMIZATION.md). The
22
+ cache is bounded by the text of the programs; a 1M-entry guard covers
23
+ dynamic uses through the API."""
26
24
  if base is not None:
27
- return rdflib.URIRef(value, base)
25
+ return _URIRef(value, base)
28
26
  u = _URI_CACHE.get(value)
29
27
  if u is None:
30
- if len(_URI_CACHE) > 1_000_000:
31
- return rdflib.URIRef(value)
32
- u = _URI_CACHE.setdefault(value, rdflib.URIRef(value))
28
+ if len(_URI_CACHE) > 1000000:
29
+ return _URIRef(value)
30
+ u = _URI_CACHE.setdefault(value, _URIRef(value))
33
31
  return u
34
32
 
35
33
  try:
@@ -152,7 +150,7 @@ def _bnode_cached(label, _cache={}):
152
150
  return b
153
151
 
154
152
 
155
- _PASSTHROUGH = frozenset((rdflib.URIRef, Literal, BNode, Variable, bn))
153
+ _PASSTHROUGH = frozenset((_URIRef, Literal, BNode, Variable, bn))
156
154
 
157
155
 
158
156
  _COERCION_STACK = []
@@ -210,11 +208,11 @@ class Coercion:
210
208
 
211
209
  def _apply_conv(conv, value):
212
210
  """Apply a Coercion conversion: datatype, IRI, or function."""
213
- if conv is rdflib.URIRef or conv is URIRef:
214
- return rdflib.URIRef(str(value))
211
+ if conv is _URIRef or conv is URIRef:
212
+ return _URIRef(str(value))
215
213
  if conv is Literal:
216
214
  return Literal(value)
217
- if isinstance(conv, rdflib.URIRef): # un datatype (XSD.integer, ...)
215
+ if isinstance(conv, _URIRef): # un datatype (XSD.integer, ...)
218
216
  return Literal(value, datatype=conv)
219
217
  return node(conv(value))
220
218
 
@@ -245,7 +243,7 @@ def dtype(value):
245
243
  A datatype is ALWAYS an IRI: unlike node(), a string is therefore read as
246
244
  an IRI and not as a literal. Also accepts an object exposing its IRI
247
245
  through str() (URIRef, a DefinedNamespace term)."""
248
- if type(value) is rdflib.URIRef:
246
+ if type(value) is _URIRef:
249
247
  return value
250
248
  if isinstance(value, str):
251
249
  return URIRef(value)
@@ -269,7 +267,7 @@ def pname(ns, *parts):
269
267
  tp = type(p)
270
268
  if tp is Variable or (cls and tp is cls):
271
269
  return _deferred_pname(ns, parts)
272
- return rdflib.URIRef(
270
+ return _URIRef(
273
271
  str(ns) + "".join(p if isinstance(p, str) else str(p) for p in parts))
274
272
 
275
273
 
@@ -292,7 +290,7 @@ def _deferred_pname(ns, parts):
292
290
  out.append(str(p(sm)))
293
291
  else:
294
292
  out.append(p if isinstance(p, str) else str(p))
295
- return rdflib.URIRef("".join(out))
293
+ return _URIRef("".join(out))
296
294
 
297
295
  src = str(ns) + "".join(
298
296
  "?%s" % p if type(p) is Variable
@@ -308,7 +306,19 @@ def firi(*parts, base=None):
308
306
  return URIRef(iri)
309
307
 
310
308
 
311
- _graph_ids = itertools.count()
309
+ class _Counter:
310
+ """itertools.count(), which MicroPython does not ship."""
311
+ __slots__ = ("n",)
312
+
313
+ def __init__(self):
314
+ self.n = 0
315
+
316
+ def __next__(self):
317
+ self.n += 1
318
+ return self.n - 1
319
+
320
+
321
+ _graph_ids = _Counter()
312
322
  _NM_CACHE = {}
313
323
 
314
324
 
@@ -320,14 +330,14 @@ def _nm_for(namespaces):
320
330
  produced — the same sharing pattern as instantiateBGP. The cache is
321
331
  invalidated by a snapshot of the content (identity alone is not enough:
322
332
  block scope obliges)."""
323
- if not namespaces:
333
+ if not namespaces or not _backend.HAS_NAMESPACE_MANAGER:
324
334
  return None
325
335
  key = id(namespaces)
326
336
  snap = tuple(sorted((p, str(u)) for p, u in namespaces.items()))
327
337
  hit = _NM_CACHE.get(key)
328
338
  if hit is not None and hit[0] == snap:
329
339
  return hit[1]
330
- holder = rdflib.Graph()
340
+ holder = _Graph()
331
341
  nm = holder.namespace_manager
332
342
  for prefix, ns in namespaces.items():
333
343
  nm.bind(prefix, ns, replace=True)
@@ -335,7 +345,14 @@ def _nm_for(namespaces):
335
345
  return nm
336
346
 
337
347
 
338
- class _EmittedGraph(rdflib.Graph):
348
+ def _new_emitted(base):
349
+ """A fresh emitted graph with the next identifier — lazy over rdflib,
350
+ eager over urdflib (the class is chosen once, below)."""
351
+ return _EMITTED(base=base,
352
+ identifier=_URIRef("urn:x-ldpy:g%d" % next(_graph_ids)))
353
+
354
+
355
+ class _EmittedGraph(_Graph):
339
356
  """The graph emitted by g{...}, materialised LAZILY.
340
357
 
341
358
  The triples wait in a list; the store is only populated on the first real
@@ -392,9 +409,7 @@ class _EmittedGraph(rdflib.Graph):
392
409
  pending = self._pending
393
410
  if pending is None:
394
411
  pending = list(super().__iter__())
395
- g = _EmittedGraph(base=self.base,
396
- identifier=rdflib.URIRef("urn:x-ldpy:g%d"
397
- % next(_graph_ids)))
412
+ g = _new_emitted(self.base)
398
413
  nm = self._Graph__namespace_manager
399
414
  if nm is not None:
400
415
  g.namespace_manager = nm
@@ -419,9 +434,7 @@ class _EmittedGraph(rdflib.Graph):
419
434
 
420
435
  def _merged(self, left, right):
421
436
  """Nouveau graphe paresseux contenant left puis right (listes)."""
422
- g = _EmittedGraph(base=self.base,
423
- identifier=rdflib.URIRef("urn:x-ldpy:g%d"
424
- % next(_graph_ids)))
437
+ g = _new_emitted(self.base)
425
438
  nm = self._Graph__namespace_manager
426
439
  if nm is not None:
427
440
  g.namespace_manager = nm
@@ -435,7 +448,7 @@ class _EmittedGraph(rdflib.Graph):
435
448
  O(total) instead of O(n²) store insertions; deduplication (union
436
449
  semantics) is still guaranteed at flush time."""
437
450
  pending = self._pending
438
- if pending is not None and isinstance(other, rdflib.Graph):
451
+ if pending is not None and isinstance(other, _Graph):
439
452
  if type(other) is _EmittedGraph and other._pending is not None:
440
453
  return self._merged(pending, other._pending)
441
454
  return self._merged(pending, list(other))
@@ -446,7 +459,7 @@ class _EmittedGraph(rdflib.Graph):
446
459
  boucle d'accumulation `g = g{ } ; g += g{…}` reste O(total) en
447
460
  store insertions instead of paying one per turn."""
448
461
  pending = self._pending
449
- if pending is not None and isinstance(other, rdflib.Graph):
462
+ if pending is not None and isinstance(other, _Graph):
450
463
  if type(other) is _EmittedGraph and other._pending is not None:
451
464
  self._pending = pending + other._pending
452
465
  else:
@@ -457,11 +470,72 @@ class _EmittedGraph(rdflib.Graph):
457
470
  def __radd__(self, other):
458
471
  """Graph() + g{...} (and therefore sum(...)) with no materialisation."""
459
472
  pending = self._pending
460
- if pending is not None and isinstance(other, rdflib.Graph):
473
+ if pending is not None and isinstance(other, _Graph):
461
474
  return self._merged(list(other), pending)
462
475
  return NotImplemented
463
476
 
464
477
 
478
+ class _EagerEmittedGraph(_Graph):
479
+ """The graph emitted by g{...} over urdflib: EAGER. The laziness
480
+ above rides on rdflib's private store attribute; a C graph has none,
481
+ and adds in place at C speed, so the triples go straight in. The
482
+ algebra the emitted code relies on stays: g1 + g2, g += x, g{ }(b)."""
483
+
484
+ def __init__(self, base=None, identifier=None):
485
+ _Graph.__init__(self, identifier=identifier) if identifier is not None \
486
+ else _Graph.__init__(self)
487
+ self.base = base
488
+ self._pending = None
489
+ self._Graph__namespace_manager = None
490
+ self._nm_namespaces = None
491
+
492
+ @property
493
+ def namespace_manager(self):
494
+ return self._Graph__namespace_manager
495
+
496
+ @namespace_manager.setter
497
+ def namespace_manager(self, nm):
498
+ self._Graph__namespace_manager = nm
499
+
500
+ def _fill(self, triples):
501
+ for tr in triples:
502
+ self.add(tr)
503
+ return self
504
+
505
+ def __call__(self, bindings=None):
506
+ g = _new_emitted(self.base)
507
+ term0 = _materializer(bindings if bindings is not None else {},
508
+ keep_vars=False)
509
+ bm = {}
510
+
511
+ def term(x):
512
+ v = term0(x)
513
+ if type(v) is BNode:
514
+ b = bm.get(v)
515
+ if b is None:
516
+ b = bm[v] = BNode()
517
+ return b
518
+ return v
519
+
520
+ return g._fill(tr for tr in
521
+ ((term(s), term(p), term(o)) for s, p, o in self)
522
+ if tr[0] is not None and tr[1] is not None
523
+ and tr[2] is not None)
524
+
525
+ def __add__(self, other):
526
+ return _new_emitted(self.base)._fill(self)._fill(other)
527
+
528
+ def __iadd__(self, other):
529
+ return self._fill(other)
530
+
531
+ def __radd__(self, other):
532
+ return _new_emitted(self.base)._fill(other)._fill(self)
533
+
534
+
535
+ #: The emitted-graph class of this backend: a C graph has no store hook.
536
+ _EMITTED = _EmittedGraph if _backend.NAME == "rdflib" else _EagerEmittedGraph
537
+
538
+
465
539
  class Bindings(dict):
466
540
  """The current bindings (record ldpy/017): a dict with str or Variable
467
541
  keys (normalised to str), values coerced into RDF terms on assignment.
@@ -625,10 +699,12 @@ def _materializer(bindings=None, keep_vars=True):
625
699
  def new_graph(namespaces, base, identifier=None):
626
700
  """A graph created by '@graph as g' (record ldpy/014): a plain
627
701
  rdflib.Graph, with the serialisation bindings of the prefixes in scope."""
628
- g = rdflib.Graph(base=base, identifier=identifier)
702
+ g = _backend.new_graph(base=base, identifier=identifier)
629
703
  nm = _nm_for(namespaces)
630
704
  if nm is not None:
631
705
  g.namespace_manager = nm
706
+ elif namespaces and not _backend.HAS_NAMESPACE_MANAGER:
707
+ _backend.bind_namespaces(g, namespaces)
632
708
  return g
633
709
 
634
710
 
@@ -682,21 +758,25 @@ def graph(namespaces, base, *triples, bindings=None):
682
758
  With no bindings, a g{ } with variables or e{ } stays a template; with
683
759
  the current bindings in scope, it is instantiated (record ldpy/017) — a
684
760
  triple with a term still unbound is dropped."""
685
- g = _EmittedGraph(base=base,
686
- identifier=rdflib.URIRef("urn:x-ldpy:g%d"
687
- % next(_graph_ids)))
761
+ g = _new_emitted(base)
688
762
  nm = _nm_for(namespaces)
689
763
  if nm is not None:
690
764
  g.namespace_manager = nm
765
+ elif namespaces and not _backend.HAS_NAMESPACE_MANAGER:
766
+ _backend.bind_namespaces(g, namespaces)
691
767
  _term = _materializer(bindings, keep_vars=(bindings is None))
692
768
  if bindings is None:
693
- g._pending = [(_term(s), _term(p), _term(o)) for s, p, o in triples]
769
+ pending = [(_term(s), _term(p), _term(o)) for s, p, o in triples]
694
770
  else:
695
- g._pending = [tr for tr in
696
- ((_term(s), _term(p), _term(o))
697
- for s, p, o in triples)
698
- if tr[0] is not None and tr[1] is not None
699
- and tr[2] is not None]
771
+ pending = [tr for tr in
772
+ ((_term(s), _term(p), _term(o))
773
+ for s, p, o in triples)
774
+ if tr[0] is not None and tr[1] is not None
775
+ and tr[2] is not None]
776
+ if _backend.NAME == "rdflib":
777
+ g._pending = pending
778
+ else:
779
+ g._fill(pending)
700
780
  return g
701
781
 
702
782
 
@@ -728,6 +808,51 @@ class Row(tuple):
728
808
  for i, f in enumerate(self._fields))
729
809
 
730
810
 
811
+ class _RowSeq:
812
+ """The same Row for MicroPython, which cannot subclass tuple with
813
+ __new__: a small sequence that unpacks, compares to a tuple, and
814
+ answers to names and variables."""
815
+
816
+ __slots__ = ("_values", "_fields")
817
+
818
+ def __init__(self, values, fields):
819
+ self._values = tuple(values)
820
+ self._fields = fields
821
+
822
+ def __iter__(self):
823
+ return iter(self._values)
824
+
825
+ def __len__(self):
826
+ return len(self._values)
827
+
828
+ def __eq__(self, other):
829
+ return self._values == (other._values if isinstance(other, _RowSeq)
830
+ else other)
831
+
832
+ def __hash__(self):
833
+ return hash(self._values)
834
+
835
+ def __getattr__(self, name):
836
+ try:
837
+ return self._values[self._fields.index(name)]
838
+ except ValueError:
839
+ raise AttributeError(name)
840
+
841
+ def __getitem__(self, key):
842
+ if isinstance(key, (str, Variable)):
843
+ return self._values[self._fields.index(str(key))]
844
+ return self._values[key]
845
+
846
+ def __repr__(self):
847
+ return "Row(%s)" % ", ".join(
848
+ "%s=%r" % (f, v) for f, v in zip(self._fields, self._values))
849
+
850
+
851
+ #: The row class of this Python: tuple-based, unless tuple cannot be
852
+ #: subclassed with __new__ (MicroPython).
853
+ _ROW = Row if hasattr(tuple, "__new__") else _RowSeq
854
+
855
+
731
856
  def _match_prepare(patterns, bindings):
732
857
  """Patterns ready for matching: bn -> an anonymous variable shared by
733
858
  index, slot -> a value, variables bound by the bindings -> a term."""
@@ -822,7 +947,7 @@ class Match:
822
947
  else:
823
948
  vs = [Variable(x) for x in proj]
824
949
  for sm in self.solutions():
825
- yield Row((sm.get(x) for x in vs), list(proj))
950
+ yield _ROW((sm.get(x) for x in vs), list(proj))
826
951
 
827
952
  def __bool__(self):
828
953
  for _ in self.solutions():
@@ -875,9 +1000,7 @@ def _prepare_sparql(text, namespaces, update):
875
1000
  hit = _SPARQL_CACHE.get(key)
876
1001
  if hit is not None:
877
1002
  return hit
878
- from rdflib.plugins.sparql import prepareQuery, prepareUpdate
879
- prep = (prepareUpdate if update else prepareQuery)(
880
- text, initNs=dict(namespaces))
1003
+ prep = _backend.prepare_sparql(text, namespaces, update)
881
1004
  if len(_SPARQL_CACHE) >= _SPARQL_CACHE_MAX:
882
1005
  _SPARQL_CACHE.pop(next(iter(_SPARQL_CACHE)))
883
1006
  _SPARQL_CACHE[key] = prep
@@ -962,8 +1085,9 @@ def instantiateBGP(input, solutionMappings, initialGraph=None):
962
1085
 
963
1086
  Carried over from v1 (ldpy/ldpy.py), functionally unchanged."""
964
1087
  if initialGraph is None:
965
- initialGraph = rdflib.Graph(base=input.base)
966
- initialGraph.namespace_manager = input.namespace_manager
1088
+ initialGraph = _backend.new_graph(base=input.base)
1089
+ if _backend.HAS_NAMESPACE_MANAGER:
1090
+ initialGraph.namespace_manager = input.namespace_manager
967
1091
  if solutionMappings is None:
968
1092
  return initialGraph
969
1093
  if isinstance(solutionMappings, dict):
@@ -11,14 +11,14 @@ later, against a *solution mapping*:
11
11
  The semantics follow SPARQL 1.1: numeric promotion (integer < decimal <
12
12
  float < double, integer division -> decimal), errors PROPAGATED (unbound
13
13
  variable, incomparable types) and absorbed by ``||``/``&&``/``IF``/
14
- ``COALESCE`` per SPARQL's three-valued truth table. No rdflib parser is used;
15
- rdflib only serves as the data model.
14
+ ``COALESCE`` per SPARQL's three-valued truth table. No parser of any RDF
15
+ library is used: the terms come from ``ldpy.backend`` — rdflib or urdflib —
16
+ so these expressions run on a device as well as on the host.
16
17
  """
17
18
 
18
19
  import re as _re
19
20
 
20
- from rdflib import BNode, Literal, URIRef, Variable, XSD
21
- from rdflib.term import Node
21
+ from ldpy.backend import BNode, Literal, URIRef, Variable, XSD, Node
22
22
 
23
23
  from ldpy.runtime import node as _node
24
24
 
@@ -217,7 +217,18 @@ def _term_kind(text):
217
217
  class Transpiler:
218
218
  """Une instance par fichier. Usage : Transpiler(src, filename).run()."""
219
219
 
220
- def __init__(self, text, filename="<ldpy>", emit_prelude=True):
220
+ #: The targets a programme can be transpiled for. `None` is the host;
221
+ #: "micropython" is a device whose backend has no SPARQL engine, so
222
+ #: s{ } is refused HERE, at build time on the host, where the message
223
+ #: can be read — not on the device, at the first query.
224
+ TARGETS = (None, "micropython")
225
+
226
+ def __init__(self, text, filename="<ldpy>", emit_prelude=True,
227
+ target=None):
228
+ if target not in self.TARGETS:
229
+ raise ValueError("unknown target %r (one of %s)"
230
+ % (target, ", ".join(repr(t) for t in self.TARGETS)))
231
+ self.target = target
221
232
  self.text = text
222
233
  self.emit_prelude = emit_prelude
223
234
  self.n = len(text)
@@ -2090,6 +2101,11 @@ class Transpiler:
2090
2101
  transpilation (l'oracle, fiche 015)."""
2091
2102
  mark = self._begin_island()
2092
2103
  isl_line, isl_col = self.src_line, self.src_col
2104
+ if self.target == "micropython":
2105
+ self._error("s{ } is not available on the micropython target: "
2106
+ "a SPARQL query needs rdflib's engine, which the "
2107
+ "device does not have — write the pattern with "
2108
+ "m{ } and the filter with e{ }", incomplete=False)
2093
2109
  self._take(2) # s{
2094
2110
  t = self.text
2095
2111
  pieces = []
@@ -2697,9 +2713,10 @@ class _GraphCtx:
2697
2713
  return "%s.bn(%d)" % (RUNTIME_ALIAS, self.labels[label])
2698
2714
 
2699
2715
 
2700
- def transpile(source, filename="<ldpy>"):
2716
+ def transpile(source, filename="<ldpy>", target=None):
2701
2717
  """Transpile un source Linked-Data Python en Python.
2702
2718
 
2719
+ `target` : None (l'hôte) ou "micropython" — voir Transpiler.TARGETS.
2703
2720
  Retourne un TranspileResult(code, map, prefixes, base, warnings).
2704
2721
  """
2705
- return Transpiler(source, filename).run()
2722
+ return Transpiler(source, filename, target=target).run()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: linked-data-python
3
- Version: 0.5.2
3
+ Version: 0.6.1
4
4
  Summary: Python extended with Semantic Web primitives: IRIs, RDF literals and Turtle-notation graphs as expressions, transpiled by island parsing.
5
5
  Author-email: Maxime Lefrançois <maxime.lefrancois@emse.fr>
6
6
  License: MIT
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  ldpy/__init__.py
5
5
  ldpy/__main__.py
6
+ ldpy/backend.py
6
7
  ldpy/build.py
7
8
  ldpy/console.py
8
9
  ldpy/debug.py
@@ -64,4 +65,5 @@ tests/test_scope_modifiers.py
64
65
  tests/test_semantics.py
65
66
  tests/test_sourcemap_v3.py
66
67
  tests/test_sparql_expr.py
67
- tests/test_sparql_island.py
68
+ tests/test_sparql_island.py
69
+ tests/test_target_micropython.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "linked-data-python"
7
- version = "0.5.2"
7
+ version = "0.6.1"
8
8
  description = "Python extended with Semantic Web primitives: IRIs, RDF literals and Turtle-notation graphs as expressions, transpiled by island parsing."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -20,13 +20,21 @@ def blocks(lang):
20
20
  for path in DOCS:
21
21
  text = open(path, encoding="utf-8").read()
22
22
  for i, m in enumerate(FENCE.finditer(text)):
23
- if m.group(1) == lang:
23
+ if m.group(1) == lang and not _is_snippet(m.group(2)):
24
24
  out.append(pytest.param(
25
25
  m.group(2),
26
26
  id="%s#%d" % (os.path.relpath(path, REPO), i)))
27
27
  return out
28
28
 
29
29
 
30
+ def _is_snippet(code):
31
+ """A block that only includes a file (``--8<-- "..."``) has no code of its
32
+ own: mkdocs substitutes the file at build time, and that file is tested
33
+ where it lives — see ``test_the_tour_runs``."""
34
+ return all(line.strip().startswith("--8<--") or not line.strip()
35
+ for line in code.split("\n"))
36
+
37
+
30
38
  def test_docs_exist_and_cover_diataxis():
31
39
  rels = {os.path.relpath(p, os.path.join(REPO, "docs")) for p in DOCS}
32
40
  assert "README.md" in rels
@@ -46,6 +54,18 @@ def test_python_snippets_run(code):
46
54
  exec(compile(code, "<docs>", "exec"), {"__name__": "docs"})
47
55
 
48
56
 
57
+ def test_the_tour_runs():
58
+ """``docs/tutorials/tour.ldpy`` is a tutorial in the shape of a program:
59
+ its comments teach, and each section asserts what it claims. Running it is
60
+ therefore the whole check — and the page that shows it includes this very
61
+ file, so the two cannot drift."""
62
+ from ldpy.transpiler import transpile
63
+ path = os.path.join(REPO, "docs", "tutorials", "tour.ldpy")
64
+ source = open(path, encoding="utf-8").read()
65
+ exec(compile(transpile(source, path).code, path, "exec"),
66
+ {"__name__": "tour"})
67
+
68
+
49
69
  # --------------------------------------------------------------------------
50
70
  # The same snippets, typed into the interactive console.
51
71
  #
@@ -0,0 +1,134 @@
1
+ """The micropython target and the backend behind the façade.
2
+
3
+ Two optionals, on two axes (record ldpy/026):
4
+
5
+ * the BACKEND — rdflib on the host, urdflib on a device — is chosen by
6
+ `ldpy.backend` from what is importable, and the generated code is the
7
+ same on both;
8
+ * SPARQL QUERIES (`s{ }`) are a capability of the rdflib backend only.
9
+ `--target micropython` refuses them at build time, on the host, where the
10
+ message can be read; `e{ }` and `m{ }` are pure Python over the terms and
11
+ stay.
12
+
13
+ The last test runs the emitted code on a real MicroPython, when one built
14
+ with urdflib is at hand (LDPY_MICROPYTHON, or `micropython` on the PATH).
15
+ """
16
+
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+
22
+ import pytest
23
+
24
+ from ldpy import backend
25
+ from ldpy.transpiler import transpile, LdpySyntaxError
26
+ from ldpy.build import bundle_device_runtime, DEVICE_RUNTIME
27
+
28
+ P = "@prefix ex: <http://example.org/> .\n"
29
+
30
+
31
+ def test_backend_is_rdflib_on_the_host():
32
+ assert backend.NAME == "rdflib" and backend.HAS_SPARQL
33
+ from ldpy import runtime
34
+ assert runtime.URIRef("http://x") == backend.URIRef("http://x")
35
+ assert isinstance(runtime.new_graph({}, None), backend.Graph)
36
+
37
+
38
+ def test_target_refuses_a_sparql_query_at_build_time():
39
+ src = P + "@graph as g\nq = s{ SELECT ?s WHERE { ?s ?p ?o } }\n"
40
+ transpile(src) # the host accepts it
41
+ with pytest.raises(LdpySyntaxError) as e:
42
+ transpile(src, target="micropython")
43
+ assert "s{ } is not available on the micropython target" in str(e.value)
44
+ assert "m{ }" in str(e.value) # and says what to use
45
+
46
+
47
+ def test_target_keeps_expressions_patterns_and_graphs():
48
+ src = (P + "@graph as g\n+{ ex:a ex:p 1 }\n"
49
+ "adult = e{ ?age >= 18 }\n"
50
+ "rows = m{ ?s ex:p ?o }\n"
51
+ "h = g{ ex:b ex:q 2 }\n")
52
+ assert transpile(src, target="micropython").code == transpile(src).code, \
53
+ "the target changes what is refused, not what is emitted"
54
+
55
+
56
+ def test_unknown_target_is_an_error():
57
+ with pytest.raises(ValueError):
58
+ transpile("x = 1\n", target="esp8266")
59
+
60
+
61
+ def test_device_bundle_is_the_runtime_without_the_transpiler(tmp_path):
62
+ written = bundle_device_runtime(str(tmp_path))
63
+ names = sorted(os.path.basename(p) for p in written)
64
+ assert names == sorted(("__init__.py",) + DEVICE_RUNTIME)
65
+ init = (tmp_path / "ldpy" / "__init__.py").read_text()
66
+ assert "transpiler" not in init.replace("the transpiler is not here", "")
67
+ for name in DEVICE_RUNTIME:
68
+ assert (tmp_path / "ldpy" / name).read_bytes() == \
69
+ open(os.path.join(os.path.dirname(backend.__file__), name),
70
+ "rb").read()
71
+
72
+
73
+ def test_device_runtime_imports_no_rdflib_module_by_name():
74
+ """What ships to the device must reach rdflib only through the backend."""
75
+ here = os.path.dirname(backend.__file__)
76
+ for name in ("runtime.py", "sparql.py"):
77
+ text = open(os.path.join(here, name), encoding="utf-8").read()
78
+ code = "\n".join(l for l in text.splitlines()
79
+ if not l.strip().startswith("#"))
80
+ assert "import rdflib" not in code and "from rdflib" not in code, name
81
+
82
+
83
+ # ---------------------------------------------------------------- device
84
+
85
+ def _micropython():
86
+ exe = os.environ.get("LDPY_MICROPYTHON") or shutil.which("micropython")
87
+ if not exe:
88
+ return None
89
+ probe = subprocess.run([exe, "-c", "import urdflib"], capture_output=True)
90
+ return exe if probe.returncode == 0 else None
91
+
92
+
93
+ DEVICE_PROGRAM = P + '''\
94
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
95
+
96
+ @graph as g
97
+ for i in range(3):
98
+ +{ f<http://example.org/obs/{i}> a ex:Observation ;
99
+ ex:value {i * 1.5}^^xsd:double ;
100
+ ex:label {"n%d" % i}@en }
101
+ big = e{ ?v >= 1.5 }
102
+ kept = sorted(str(s) for s, v in m{ ?s ex:value ?v } if big({"v": v}))
103
+ print(len(g), kept)
104
+ print(sorted(str(o) + "@" + str(o.language) for s, o in m{ ?s ex:label ?o }))
105
+ print(sorted(row.s.n3() for row in m{ ?s ex:value ?v } if row.v.toPython() > 1))
106
+ ttl = g.serialize(format="turtle")
107
+ print("@prefix ex:" in ttl, "ex:Observation" in ttl)
108
+ '''
109
+
110
+
111
+ @pytest.mark.skipif(_micropython() is None,
112
+ reason="no MicroPython with urdflib (LDPY_MICROPYTHON)")
113
+ def test_the_same_program_runs_on_micropython(tmp_path):
114
+ """Transpile on the host, run on the device — mode 1 of record
115
+ micropython/304 — and get the answer the host gets."""
116
+ out = tmp_path / "build"
117
+ out.mkdir()
118
+ bundle_device_runtime(str(out))
119
+ result = transpile(DEVICE_PROGRAM, "device.ldpy", target="micropython")
120
+ (out / "device.py").write_text(result.code)
121
+
122
+ host = subprocess.run([sys.executable, "-c", result.code],
123
+ capture_output=True, text=True, check=True)
124
+ env = dict(os.environ, MICROPYPATH=str(out))
125
+ device = subprocess.run([_micropython(), str(out / "device.py")],
126
+ capture_output=True, text=True, env=env)
127
+ assert device.returncode == 0, device.stderr
128
+ assert device.stdout == host.stdout
129
+ assert host.stdout.splitlines() == [
130
+ "9 ['http://example.org/obs/1', 'http://example.org/obs/2']",
131
+ "['n0@en', 'n1@en', 'n2@en']",
132
+ "['<http://example.org/obs/1>', '<http://example.org/obs/2>']",
133
+ "True True",
134
+ ]