linked-data-python 0.0.4__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- ldpy/__init__.py +35 -11
- ldpy/__main__.py +75 -250
- ldpy/build.py +91 -0
- ldpy/console.py +116 -0
- ldpy/debug.py +235 -0
- ldpy/formatter.py +329 -0
- ldpy/importer.py +110 -0
- ldpy/lsp/__init__.py +11 -0
- ldpy/lsp/__main__.py +4 -0
- ldpy/lsp/backend.py +118 -0
- ldpy/lsp/rpc.py +104 -0
- ldpy/lsp/server.py +353 -0
- ldpy/lsp/translate.py +140 -0
- ldpy/pygments_lexer.py +613 -0
- ldpy/runtime.py +931 -0
- ldpy/sparql.py +551 -0
- ldpy/transpiler/__init__.py +14 -0
- ldpy/transpiler/core.py +2558 -0
- ldpy/transpiler/errors.py +38 -0
- ldpy/transpiler/linemap.py +292 -0
- linked_data_python-0.2.0.dist-info/METADATA +158 -0
- linked_data_python-0.2.0.dist-info/RECORD +26 -0
- {linked_data_python-0.0.4.dist-info → linked_data_python-0.2.0.dist-info}/WHEEL +1 -1
- linked_data_python-0.2.0.dist-info/entry_points.txt +9 -0
- {linked_data_python-0.0.4.dist-info → linked_data_python-0.2.0.dist-info/licenses}/LICENSE.md +0 -0
- {linked_data_python-0.0.4.dist-info → linked_data_python-0.2.0.dist-info}/top_level.txt +0 -0
- ldpy/grun/lib.py +0 -63
- ldpy/grun/util.py +0 -11
- ldpy/ldpy.py +0 -183
- ldpy/rewriter/IndentedStringWriter.py +0 -54
- ldpy/rewriter/LDPythonRewriter.py +0 -677
- ldpy/rewriter/MultiChannelTokenStream.py +0 -127
- ldpy/rewriter/Result.py +0 -49
- ldpy/rewriter/__init__.py +0 -7
- ldpy/rewriter/antlr/LDPythonLexer.py +0 -870
- ldpy/rewriter/antlr/LDPythonParser.py +0 -9336
- ldpy/rewriter/antlr/LDPythonVisitor.py +0 -573
- ldpy/sparql/builtin.py +0 -326
- linked_data_python-0.0.4.dist-info/METADATA +0 -139
- linked_data_python-0.0.4.dist-info/RECORD +0 -20
- linked_data_python-0.0.4.dist-info/entry_points.txt +0 -3
ldpy/debug.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Debugging .ldpy files (docs/explanation/tooling.md).
|
|
2
|
+
|
|
3
|
+
Principle: NO DAP adapter to write. Two modes:
|
|
4
|
+
|
|
5
|
+
- **direct** (`--run`, record ldpy/011): the .ldpy is compiled in source
|
|
6
|
+
coordinates (compile_mapped) and run IN this process. Started under
|
|
7
|
+
debugpy (by the VS Code extension: `python -m debugpy … -m ldpy.debug
|
|
8
|
+
--run f.ldpy`), breakpoints set in the .ldpy bind directly — no shadow,
|
|
9
|
+
no translation;
|
|
10
|
+
- **shadow**: `ldpy.build` materialises a real Python file and its maps;
|
|
11
|
+
debugpy (or any Python tool) runs on it as it is
|
|
12
|
+
(`python -m ldpy.debug file.ldpy [--listen H:P] [-- args]`), and
|
|
13
|
+
`--breakpoints` translates .ldpy lines <-> shadow lines for tooling.
|
|
14
|
+
|
|
15
|
+
The direct mode leaves three frames of THIS module below the user's own.
|
|
16
|
+
`stepping_rules()` tells the debugger to ignore them (record vscode/103);
|
|
17
|
+
`--probe` publishes them so the VS Code extension need not restate them.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
from ldpy.build import build_file, DEFAULT_OUT
|
|
27
|
+
from ldpy.transpiler import LdpySyntaxError, transpile
|
|
28
|
+
from ldpy.transpiler.linemap import (LanguageMap, compile_mapped,
|
|
29
|
+
snap_breakpoint_line,
|
|
30
|
+
snap_breakpoint_lines)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Stepping filters (record vscode/103)
|
|
35
|
+
#
|
|
36
|
+
# The invariant: every debugger stop selects a region of the .ldpy file.
|
|
37
|
+
# Two things break it — the LAUNCHER frames (this module, below the user's)
|
|
38
|
+
# and, when nobody asked for them, the RUNTIME ones (stepping into
|
|
39
|
+
# `_ldpy_.graph(...)` lands in runtime.py). Both are settled by the protocol's
|
|
40
|
+
# `rules`: pydevd reads them from the DAP `launch`/`attach` request (the
|
|
41
|
+
# PYDEVD_FILTERS environment variable, on the other hand, is overwritten by
|
|
42
|
+
# that request — measured, do not rely on it).
|
|
43
|
+
#
|
|
44
|
+
# The policy, in two tiers:
|
|
45
|
+
# - the launcher is ALWAYS hidden: it is plumbing, never code anyone
|
|
46
|
+
# wants to look at;
|
|
47
|
+
# - the rest of the package only under `justMyCode` (the default).
|
|
48
|
+
# `justMyCode: false` is the explicit request to see everything: a step
|
|
49
|
+
# in then descends into the runtime, which is the intended behaviour.
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
#: Modules that are only the launcher: never visible, whatever the mode.
|
|
53
|
+
LAUNCHER_FILES = ("debug.py", "__main__.py")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def package_dir():
|
|
57
|
+
"""Directory of the installed ldpy package (the one running this code)."""
|
|
58
|
+
return os.path.dirname(os.path.abspath(__file__))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def stepping_rules(just_my_code=True, package=None):
|
|
62
|
+
"""The DAP `rules` that hold the invariant of record vscode/103.
|
|
63
|
+
|
|
64
|
+
Shape debugpy expects: a list of {"path": glob, "include": bool}, first
|
|
65
|
+
premier motif qui matche gagnant."""
|
|
66
|
+
pkg = package or package_dir()
|
|
67
|
+
rules = [{"path": os.path.join(pkg, name), "include": False}
|
|
68
|
+
for name in LAUNCHER_FILES]
|
|
69
|
+
if just_my_code:
|
|
70
|
+
rules.append({"path": os.path.join(pkg, "**"), "include": False})
|
|
71
|
+
return rules
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def probe():
|
|
75
|
+
"""What a debug client needs in order to launch ldpy correctly.
|
|
76
|
+
|
|
77
|
+
The VS Code extension calls `python -m ldpy.debug --probe`: a single
|
|
78
|
+
process gives it proof that the package is importable, its version, and
|
|
79
|
+
the stepping rules — which thus stay described in ONE place."""
|
|
80
|
+
import ldpy
|
|
81
|
+
return {
|
|
82
|
+
"package": package_dir(),
|
|
83
|
+
"version": getattr(ldpy, "__version__", None),
|
|
84
|
+
"python": sys.executable,
|
|
85
|
+
"rules": {
|
|
86
|
+
"justMyCode": stepping_rules(True),
|
|
87
|
+
"all": stepping_rules(False),
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
#: re-exported: debug tooling looks for them here (pure functions, defined
|
|
93
|
+
#: alongside the map in ldpy/transpiler/linemap.py)
|
|
94
|
+
__all__ = ["stepping_rules", "probe", "package_dir", "load_map",
|
|
95
|
+
"translate_breakpoints", "translate_frames",
|
|
96
|
+
"snap_breakpoint_line", "snap_breakpoint_lines",
|
|
97
|
+
"run_direct", "main"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def load_map(map_path):
|
|
101
|
+
"""Load a LanguageMap from a .ldpy.map file (JSON)."""
|
|
102
|
+
with open(map_path, "r", encoding="utf-8") as f:
|
|
103
|
+
return LanguageMap.from_json(f.read())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def translate_breakpoints(lmap, lines_1based):
|
|
107
|
+
""".ldpy breakpoint lines (1-based) -> shadow lines (1-based).
|
|
108
|
+
|
|
109
|
+
A line with no counterpart (a comment inside a collapsed island…) snaps
|
|
110
|
+
to the generated line of the region containing it, else None."""
|
|
111
|
+
out = []
|
|
112
|
+
for line in lines_1based:
|
|
113
|
+
pos = lmap.to_gen(line - 1, 0)
|
|
114
|
+
if pos is None:
|
|
115
|
+
# inside a multi-line island: snap to its start
|
|
116
|
+
for seg in lmap.segments:
|
|
117
|
+
if seg.src and seg.src[0] <= line - 1 <= seg.src[2]:
|
|
118
|
+
pos = (seg.gen[0], seg.gen[1])
|
|
119
|
+
break
|
|
120
|
+
out.append(pos[0] + 1 if pos else None)
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def translate_frames(lmap, lines_1based):
|
|
125
|
+
"""Shadow lines (1-based) -> .ldpy lines (1-based), for stack frames."""
|
|
126
|
+
out = []
|
|
127
|
+
for line in lines_1based:
|
|
128
|
+
src = lmap.src_line_for_gen_line(line - 1)
|
|
129
|
+
out.append(src + 1 if src is not None else None)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_direct(source_path, script_args):
|
|
134
|
+
"""Direct mode: compile the .ldpy in source coordinates and run it right
|
|
135
|
+
here. Under debugpy, .ldpy breakpoints bind with no translation."""
|
|
136
|
+
import ldpy
|
|
137
|
+
src_path = os.path.abspath(source_path)
|
|
138
|
+
with open(src_path, "r", encoding="utf-8") as f:
|
|
139
|
+
source = f.read()
|
|
140
|
+
try:
|
|
141
|
+
result = transpile(source, src_path)
|
|
142
|
+
except LdpySyntaxError as e:
|
|
143
|
+
print(str(e), file=sys.stderr)
|
|
144
|
+
return 1
|
|
145
|
+
for w in result.warnings:
|
|
146
|
+
print(str(w), file=sys.stderr)
|
|
147
|
+
ldpy.install()
|
|
148
|
+
from ldpy.importer import MAPS
|
|
149
|
+
MAPS[src_path] = result.map
|
|
150
|
+
code = compile_mapped(result.code, result.map, src_path)
|
|
151
|
+
sys.argv = [src_path] + list(script_args)
|
|
152
|
+
g = {"__name__": "__main__", "__file__": src_path}
|
|
153
|
+
exec(code, g)
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def main(argv=None):
|
|
158
|
+
parser = argparse.ArgumentParser(
|
|
159
|
+
prog="ldpy.debug",
|
|
160
|
+
description="Run a .ldpy in debuggable mode: --run (direct, source "
|
|
161
|
+
"coordinates) or through its Python shadow, "
|
|
162
|
+
"under debugpy when --listen is given.")
|
|
163
|
+
parser.add_argument("source", nargs="?", help="fichier .ldpy")
|
|
164
|
+
parser.add_argument("--run", action="store_true",
|
|
165
|
+
help="run directly in this process, code compiled "
|
|
166
|
+
"in .ldpy coordinates (for debugpy: breakpoints "
|
|
167
|
+
"straight in the .ldpy)")
|
|
168
|
+
parser.add_argument("-o", "--out", default=DEFAULT_OUT,
|
|
169
|
+
help="shadow directory (default: %(default)s)")
|
|
170
|
+
parser.add_argument("--listen", metavar="HOTE:PORT",
|
|
171
|
+
help="start debugpy listening (e.g. 127.0.0.1:5678)")
|
|
172
|
+
parser.add_argument("--wait-for-client", action="store_true",
|
|
173
|
+
help="wait for the debugger to attach before starting")
|
|
174
|
+
parser.add_argument("--probe", action="store_true",
|
|
175
|
+
help="print {package, version, rules} as JSON and "
|
|
176
|
+
"exit — for the VS Code extension")
|
|
177
|
+
parser.add_argument("--breakpoints", metavar="L1,L2,...",
|
|
178
|
+
help=".ldpy lines: print their shadow lines "
|
|
179
|
+
"(JSON) and exit — for tooling")
|
|
180
|
+
parser.add_argument("args", nargs="*",
|
|
181
|
+
help="arguments passed to the script (after `--`)")
|
|
182
|
+
# argparse handles "--" poorly before a nargs="*": split it by hand
|
|
183
|
+
if argv is None:
|
|
184
|
+
argv = sys.argv[1:]
|
|
185
|
+
script_args = []
|
|
186
|
+
if "--" in argv:
|
|
187
|
+
cut = argv.index("--")
|
|
188
|
+
script_args = argv[cut + 1:]
|
|
189
|
+
argv = argv[:cut]
|
|
190
|
+
args = parser.parse_args(argv)
|
|
191
|
+
args.args = args.args + script_args
|
|
192
|
+
|
|
193
|
+
if args.probe:
|
|
194
|
+
print(json.dumps(probe()))
|
|
195
|
+
return 0
|
|
196
|
+
if not args.source:
|
|
197
|
+
parser.error("a .ldpy file is required")
|
|
198
|
+
|
|
199
|
+
if args.run:
|
|
200
|
+
return run_direct(args.source, args.args)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
py_path, map_path, result = build_file(
|
|
204
|
+
args.source, args.out,
|
|
205
|
+
rel=os.path.basename(args.source))
|
|
206
|
+
except LdpySyntaxError as e:
|
|
207
|
+
print(str(e), file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
for w in result.warnings:
|
|
210
|
+
print(str(w), file=sys.stderr)
|
|
211
|
+
|
|
212
|
+
if args.breakpoints:
|
|
213
|
+
lines = [int(x) for x in args.breakpoints.split(",") if x.strip()]
|
|
214
|
+
print(json.dumps({
|
|
215
|
+
"shadow": py_path,
|
|
216
|
+
"map": map_path,
|
|
217
|
+
"breakpoints": dict(zip(
|
|
218
|
+
lines, translate_breakpoints(result.map, lines)))}))
|
|
219
|
+
return 0
|
|
220
|
+
|
|
221
|
+
cmd = [sys.executable]
|
|
222
|
+
if args.listen:
|
|
223
|
+
cmd += ["-m", "debugpy", "--listen", args.listen]
|
|
224
|
+
if args.wait_for_client:
|
|
225
|
+
cmd += ["--wait-for-client"]
|
|
226
|
+
cmd += [py_path] + args.args
|
|
227
|
+
|
|
228
|
+
env = dict(os.environ)
|
|
229
|
+
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
230
|
+
env["PYTHONPATH"] = repo + os.pathsep + env.get("PYTHONPATH", "")
|
|
231
|
+
return subprocess.call(cmd, env=env)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
sys.exit(main())
|
ldpy/formatter.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Formateur (pretty printer) des fichiers .ldpy — fiche ldpy/024.
|
|
2
|
+
|
|
3
|
+
The principle is the highlighter's (record ldpy/023): **the formatter is the
|
|
4
|
+
transpiler**. We transpile to get the language map, which says exactly where
|
|
5
|
+
the islands are in the SOURCE; we replace each one with a stand-in that is
|
|
6
|
+
valid Python in its place; we hand the result to `black`, which is the Python
|
|
7
|
+
formatter; we put the islands back where the stand-ins landed.
|
|
8
|
+
|
|
9
|
+
Two consequences serve as the contract, and are tested:
|
|
10
|
+
|
|
11
|
+
- **host transparency** — a file with no island is formatted *exactly* as
|
|
12
|
+
`black` would format it. The formatter has no opinion about Python;
|
|
13
|
+
- **the meaning does not move** — the AST of the transpiled Python is the
|
|
14
|
+
same before and after formatting. A formatter that changes what the
|
|
15
|
+
un formateur.
|
|
16
|
+
|
|
17
|
+
What the formatter does TO THE ISLANDS is deliberately modest (the body is
|
|
18
|
+
copied verbatim, only the edges are normalised): see record ldpy/024, section
|
|
19
|
+
"what the formatter does not do".
|
|
20
|
+
|
|
21
|
+
`black` is an OPTIONAL dependency (extra `[format]`): nothing in ldpy imports
|
|
22
|
+
it outside this module, and its absence gives an actionable message.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
from ldpy.transpiler import transpile
|
|
31
|
+
|
|
32
|
+
DEFAULT_LINE_LENGTH = 88
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FormatterUnavailable(RuntimeError):
|
|
36
|
+
"""`black` is not installed in this interpreter."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _black():
|
|
40
|
+
"""The Python engine, loaded on demand.
|
|
41
|
+
|
|
42
|
+
The single coupling point: changing it (for ruff format, say) touches
|
|
43
|
+
this function and `_format_python`, nothing else."""
|
|
44
|
+
try:
|
|
45
|
+
import black
|
|
46
|
+
except ImportError as e: # pragma: no cover
|
|
47
|
+
raise FormatterUnavailable(
|
|
48
|
+
"the ldpy formatter delegates Python to black, which is not "
|
|
49
|
+
"installed — `pip install linked-data-python[format]`") from e
|
|
50
|
+
return black
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_python(text, line_length):
|
|
54
|
+
black = _black()
|
|
55
|
+
return black.format_str(text, mode=black.Mode(line_length=line_length))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Offsets: the map speaks in (line, column), text speaks in offsets
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _line_starts(text):
|
|
63
|
+
starts = [0]
|
|
64
|
+
for i, c in enumerate(text):
|
|
65
|
+
if c == "\n":
|
|
66
|
+
starts.append(i + 1)
|
|
67
|
+
return starts
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _offset(starts, line, col):
|
|
71
|
+
return starts[line] + col
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Masking: a stand-in that is valid Python in place of each island
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
#: Islands sit in an expression or a statement position, except in two cases
|
|
79
|
+
#: where the stand-in must carry the syntactic shape of the original so that
|
|
80
|
+
#: `black` treats it the same (an import earns its blank line, a bare name
|
|
81
|
+
def _substitute(kind, name):
|
|
82
|
+
if kind == "for-bindings":
|
|
83
|
+
# `for @bindings [as b] in` -> the matching loop header
|
|
84
|
+
return "for %s in" % name
|
|
85
|
+
if kind == "import":
|
|
86
|
+
# `from m import a, ex:, unit: as u:` -> une VRAIE instruction
|
|
87
|
+
# so that black handles the blank lines of the import block
|
|
88
|
+
return "import %s" % name
|
|
89
|
+
return name
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _padded(name, text, line_length):
|
|
93
|
+
"""Pad `name` so that the stand-in WEIGHS what the island weighs.
|
|
94
|
+
|
|
95
|
+
This is the masking doctrine of record ldpy/023: a stand-in of the same
|
|
96
|
+
length lets the delegated engine decide as it would have decided on the
|
|
97
|
+
real text. Here what is at stake is line breaking — a short stand-in
|
|
98
|
+
would make black believe everything fits on one line, and the island put
|
|
99
|
+
back would overflow.
|
|
100
|
+
|
|
101
|
+
A MULTI-LINE island cannot, by construction, fit on one line: we give it
|
|
102
|
+
a weight beyond the limit, which keeps the break the author wrote.
|
|
103
|
+
"""
|
|
104
|
+
width = max(len(l) for l in text.split("\n"))
|
|
105
|
+
if "\n" in text:
|
|
106
|
+
width = max(width, line_length + 1)
|
|
107
|
+
return name + "_" * max(0, width - len(name))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: An island whose text is already valid Python and has nothing to mask: the
|
|
111
|
+
#: ":" closing a `for @bindings in ... :`.
|
|
112
|
+
_TRANSPARENT = ("for-bindings-close",)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class _Island:
|
|
116
|
+
__slots__ = ("kind", "text", "column", "name", "substitute")
|
|
117
|
+
|
|
118
|
+
def __init__(self, kind, text, column, name, line_length):
|
|
119
|
+
self.kind = kind
|
|
120
|
+
self.text = text
|
|
121
|
+
self.column = column # start column in the SOURCE
|
|
122
|
+
self.name = name
|
|
123
|
+
self.substitute = _substitute(kind, _padded(name, text, line_length))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _fresh_names(source, count):
|
|
127
|
+
"""`count` identifiants uniques absents du source.
|
|
128
|
+
|
|
129
|
+
We prefix with `_L` and lengthen as long as a name appears in the text:
|
|
130
|
+
masking has to be reversible, so stand-ins must never collide with a name
|
|
131
|
+
of the user's."""
|
|
132
|
+
prefix = "_L"
|
|
133
|
+
while any(("%s%d" % (prefix, i)) in source for i in range(count)):
|
|
134
|
+
prefix += "_"
|
|
135
|
+
return ["%s%d" % (prefix, i) for i in range(count)]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _mask(source, lmap, line_length=DEFAULT_LINE_LENGTH):
|
|
139
|
+
"""Returns (masked text, island list) — the masked text is Python."""
|
|
140
|
+
starts = _line_starts(source)
|
|
141
|
+
segments = [s for s in lmap.segments
|
|
142
|
+
if s.kind.startswith("island:") and s.src is not None
|
|
143
|
+
and s.kind[len("island:"):] not in _TRANSPARENT]
|
|
144
|
+
names = _fresh_names(source, len(segments))
|
|
145
|
+
out = []
|
|
146
|
+
islands = []
|
|
147
|
+
cursor = 0
|
|
148
|
+
for seg, name in zip(segments, names):
|
|
149
|
+
kind = seg.kind[len("island:"):]
|
|
150
|
+
a = _offset(starts, seg.src[0], seg.src[1])
|
|
151
|
+
b = _offset(starts, seg.src[2], seg.src[3])
|
|
152
|
+
if a < cursor: # nested island: already covered
|
|
153
|
+
continue
|
|
154
|
+
island = _Island(kind, source[a:b], seg.src[1], name, line_length)
|
|
155
|
+
out.append(source[cursor:a])
|
|
156
|
+
out.append(island.substitute)
|
|
157
|
+
islands.append(island)
|
|
158
|
+
cursor = b
|
|
159
|
+
out.append(source[cursor:])
|
|
160
|
+
return "".join(out), islands
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Island normalisation: the EDGES only (record ldpy/024)
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
_OPENER = re.compile(r"^[a-zA-Z+\-]?\{")
|
|
168
|
+
#: `@prefix ex: <...> .` / `@base <...> .` — grammaire close, sans espace
|
|
169
|
+
#: possible inside the terms, so it normalises without risk.
|
|
170
|
+
_DECL = re.compile(r"^@(prefix|base)\s+((?:\S+:)\s+)?(<[^>\s]*>)\s*\.$")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _normalize_island(text):
|
|
174
|
+
"""Normalise an island's edges, without touching its body.
|
|
175
|
+
|
|
176
|
+
What is taken in hand: trailing whitespace, the padding right after `{`
|
|
177
|
+
and right before `}`, and the declarations whose grammar is closed
|
|
178
|
+
(`@prefix`, `@base`, `@graph`, `@bindings`, prefix imports). The body of a
|
|
179
|
+
graph or a query is copied CHARACTER FOR CHARACTER: see record ldpy/024.
|
|
180
|
+
"""
|
|
181
|
+
text = "\n".join(l.rstrip() for l in text.split("\n"))
|
|
182
|
+
|
|
183
|
+
m = _DECL.match(text)
|
|
184
|
+
if m:
|
|
185
|
+
pieces = ["@" + m.group(1)]
|
|
186
|
+
if m.group(2):
|
|
187
|
+
pieces.append(m.group(2).strip())
|
|
188
|
+
pieces += [m.group(3), "."]
|
|
189
|
+
return " ".join(pieces)
|
|
190
|
+
|
|
191
|
+
if text.startswith(("@graph", "@bindings", "from ")) \
|
|
192
|
+
and not re.search(r"[{}'\"]", text):
|
|
193
|
+
# no interpolation and no literal: the blanks there are only padding,
|
|
194
|
+
# and the comma of an import list normalises
|
|
195
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
196
|
+
return re.sub(r"\s*,\s*", ", ", text)
|
|
197
|
+
|
|
198
|
+
m = _OPENER.match(text)
|
|
199
|
+
if m and text.endswith("}") and len(text) > len(m.group(0)):
|
|
200
|
+
open_, body = m.group(0), text[len(m.group(0)):-1]
|
|
201
|
+
if not body.strip():
|
|
202
|
+
return open_ + " }"
|
|
203
|
+
first, *rest = body.split("\n")
|
|
204
|
+
if rest:
|
|
205
|
+
rest[-1] = rest[-1].rstrip()
|
|
206
|
+
body = "\n".join([" " + first.strip() if first.strip() else first]
|
|
207
|
+
+ rest)
|
|
208
|
+
return open_ + body + (" }" if rest[-1] else "}")
|
|
209
|
+
return open_ + " " + body.strip() + " }"
|
|
210
|
+
return text
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _reindent(text, from_column, to_column):
|
|
214
|
+
"""Shift the continuation lines of a multi-line island.
|
|
215
|
+
|
|
216
|
+
The shift preserves the alignment the author wanted; we never let it
|
|
217
|
+
jamais passer sous la colonne 0."""
|
|
218
|
+
if "\n" not in text or from_column == to_column:
|
|
219
|
+
return text
|
|
220
|
+
delta = to_column - from_column
|
|
221
|
+
first, *rest = text.split("\n")
|
|
222
|
+
if delta > 0:
|
|
223
|
+
rest = [(" " * delta + l) if l.strip() else l for l in rest]
|
|
224
|
+
else:
|
|
225
|
+
keep = min([len(l) - len(l.lstrip()) for l in rest if l.strip()],
|
|
226
|
+
default=0)
|
|
227
|
+
cut = min(-delta, keep)
|
|
228
|
+
rest = [l[cut:] if l.strip() else l for l in rest]
|
|
229
|
+
return "\n".join([first] + rest)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _unmask(formatted, islands):
|
|
233
|
+
"""Put each island back in place of its stand-in."""
|
|
234
|
+
for island in islands:
|
|
235
|
+
idx = formatted.find(island.substitute)
|
|
236
|
+
if idx < 0: # pragma: no cover
|
|
237
|
+
raise RuntimeError(
|
|
238
|
+
"stand-in %r not found after formatting — please report this "
|
|
239
|
+
"file as a formatter defect" % island.substitute)
|
|
240
|
+
column = idx - formatted.rfind("\n", 0, idx) - 1
|
|
241
|
+
text = _reindent(_normalize_island(island.text),
|
|
242
|
+
island.column, column)
|
|
243
|
+
formatted = (formatted[:idx] + text
|
|
244
|
+
+ formatted[idx + len(island.substitute):])
|
|
245
|
+
return formatted
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
# API
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def format_source(source, filename="<ldpy>",
|
|
253
|
+
line_length=DEFAULT_LINE_LENGTH):
|
|
254
|
+
"""Format a .ldpy source and return the formatted text.
|
|
255
|
+
|
|
256
|
+
Raises `LdpySyntaxError` if the source does not transpile (we do not
|
|
257
|
+
format what we do not understand) and `FormatterUnavailable` if black is missing."""
|
|
258
|
+
result = transpile(source, filename)
|
|
259
|
+
masked, islands = _mask(source, result.map, line_length)
|
|
260
|
+
return _unmask(_format_python(masked, line_length), islands)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def format_file(path, line_length=DEFAULT_LINE_LENGTH, write=False):
|
|
264
|
+
"""Format one file; returns (formatted text, changed)."""
|
|
265
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
266
|
+
source = f.read()
|
|
267
|
+
formatted = format_source(source, path, line_length)
|
|
268
|
+
changed = formatted != source
|
|
269
|
+
if write and changed:
|
|
270
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
271
|
+
f.write(formatted)
|
|
272
|
+
return formatted, changed
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv=None):
|
|
276
|
+
parser = argparse.ArgumentParser(
|
|
277
|
+
prog="ldpy-format",
|
|
278
|
+
description="Format .ldpy files: black for the Python, edges "
|
|
279
|
+
"normalised for the islands (record ldpy/024).")
|
|
280
|
+
parser.add_argument("paths", nargs="+",
|
|
281
|
+
help=".ldpy files or directories to walk")
|
|
282
|
+
parser.add_argument("-l", "--line-length", type=int,
|
|
283
|
+
default=DEFAULT_LINE_LENGTH,
|
|
284
|
+
help="line length (default: %(default)s)")
|
|
285
|
+
parser.add_argument("--check", action="store_true",
|
|
286
|
+
help="write nothing; exit 1 if a file is not "
|
|
287
|
+
"formatted")
|
|
288
|
+
parser.add_argument("--diff", action="store_true",
|
|
289
|
+
help="print the diff instead of writing")
|
|
290
|
+
args = parser.parse_args(sys.argv[1:] if argv is None else argv)
|
|
291
|
+
|
|
292
|
+
files = []
|
|
293
|
+
for p in args.paths:
|
|
294
|
+
if os.path.isdir(p):
|
|
295
|
+
for root, _, names in os.walk(p):
|
|
296
|
+
files += [os.path.join(root, n) for n in sorted(names)
|
|
297
|
+
if n.endswith(".ldpy")]
|
|
298
|
+
else:
|
|
299
|
+
files.append(p)
|
|
300
|
+
|
|
301
|
+
from ldpy.transpiler import LdpySyntaxError
|
|
302
|
+
changed_any = False
|
|
303
|
+
status = 0
|
|
304
|
+
for path in files:
|
|
305
|
+
try:
|
|
306
|
+
formatted, changed = format_file(
|
|
307
|
+
path, args.line_length,
|
|
308
|
+
write=not (args.check or args.diff))
|
|
309
|
+
except (LdpySyntaxError, FormatterUnavailable) as e:
|
|
310
|
+
print("%s : %s" % (path, e), file=sys.stderr)
|
|
311
|
+
status = 2
|
|
312
|
+
continue
|
|
313
|
+
changed_any = changed_any or changed
|
|
314
|
+
if args.diff and changed:
|
|
315
|
+
import difflib
|
|
316
|
+
with open(path, encoding="utf-8") as f:
|
|
317
|
+
before = f.read()
|
|
318
|
+
sys.stdout.writelines(difflib.unified_diff(
|
|
319
|
+
before.splitlines(True), formatted.splitlines(True),
|
|
320
|
+
path, path + " (formatted)"))
|
|
321
|
+
elif changed:
|
|
322
|
+
print("reformatted %s" % path)
|
|
323
|
+
if args.check and changed_any:
|
|
324
|
+
return 1
|
|
325
|
+
return status
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
if __name__ == "__main__": # pragma: no cover
|
|
329
|
+
sys.exit(main())
|
ldpy/importer.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Import hook for .ldpy modules (standard importlib, no `ideas` package).
|
|
2
|
+
|
|
3
|
+
Usage :
|
|
4
|
+
import ldpy ; ldpy.install()
|
|
5
|
+
import monmodule # trouve monmodule.ldpy sur sys.path
|
|
6
|
+
|
|
7
|
+
The .ldpy modules are compiled in SOURCE coordinates (compile_mapped,
|
|
8
|
+
fiche ldpy/011) : tracebacks, pdb et debugpy pointent
|
|
9
|
+
straight at the .ldpy lines. The LanguageMap of each imported module is
|
|
10
|
+
kept in MAPS (key: path of the .ldpy file) for tooling."""
|
|
11
|
+
|
|
12
|
+
import sys
|
|
13
|
+
import os
|
|
14
|
+
import importlib.abc
|
|
15
|
+
import importlib.machinery
|
|
16
|
+
import importlib.util
|
|
17
|
+
|
|
18
|
+
from ldpy.transpiler import transpile
|
|
19
|
+
|
|
20
|
+
MAPS = {} # chemin .ldpy -> LanguageMap
|
|
21
|
+
CODES = {} # .ldpy path -> generated Python source
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LdpyLoader(importlib.abc.SourceLoader):
|
|
25
|
+
"""Loader for .ldpy modules: transpile, then compile the source."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, fullname, path):
|
|
28
|
+
self.fullname = fullname
|
|
29
|
+
self.path = path
|
|
30
|
+
|
|
31
|
+
def get_filename(self, fullname):
|
|
32
|
+
"""Path of the .ldpy file (importlib API)."""
|
|
33
|
+
return self.path
|
|
34
|
+
|
|
35
|
+
def get_data(self, path):
|
|
36
|
+
"""Raw bytes of the source file (importlib API)."""
|
|
37
|
+
with open(path, "rb") as f:
|
|
38
|
+
return f.read()
|
|
39
|
+
|
|
40
|
+
def source_to_code(self, data, path, *, _optimize=-1):
|
|
41
|
+
"""Transpile the ldpy source, then compile it in SOURCE
|
|
42
|
+
coordinates (compile_mapped, record ldpy/011): tracebacks, pdb and
|
|
43
|
+
debugpy point straight at the .ldpy lines. The LanguageMap is kept
|
|
44
|
+
for tooling."""
|
|
45
|
+
source = data.decode("utf-8") if isinstance(data, bytes) else data
|
|
46
|
+
result = transpile(source, path)
|
|
47
|
+
for w in result.warnings:
|
|
48
|
+
print(str(w), file=sys.stderr)
|
|
49
|
+
MAPS[path] = result.map
|
|
50
|
+
CODES[path] = result.code
|
|
51
|
+
from ldpy.transpiler.linemap import compile_mapped
|
|
52
|
+
return compile_mapped(result.code, result.map, path,
|
|
53
|
+
dont_inherit=True, optimize=_optimize)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LdpyFinder(importlib.abc.MetaPathFinder):
|
|
57
|
+
"""sys.meta_path finder: resolves `import mod` to `mod.ldpy`."""
|
|
58
|
+
|
|
59
|
+
def find_spec(self, fullname, path=None, target=None):
|
|
60
|
+
"""Look for <name>.ldpy on sys.path (or on the package path)."""
|
|
61
|
+
name = fullname.rpartition(".")[2]
|
|
62
|
+
for entry in (path if path is not None else sys.path):
|
|
63
|
+
if not isinstance(entry, str):
|
|
64
|
+
continue
|
|
65
|
+
base = entry or "."
|
|
66
|
+
candidate = os.path.join(base, name + ".ldpy")
|
|
67
|
+
if os.path.isfile(candidate):
|
|
68
|
+
loader = LdpyLoader(fullname, candidate)
|
|
69
|
+
return importlib.util.spec_from_file_location(
|
|
70
|
+
fullname, candidate, loader=loader)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_finder = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def install():
|
|
78
|
+
"""Installe le finder .ldpy (idempotent)."""
|
|
79
|
+
global _finder
|
|
80
|
+
if _finder is None:
|
|
81
|
+
_finder = LdpyFinder()
|
|
82
|
+
sys.meta_path.append(_finder)
|
|
83
|
+
return _finder
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def uninstall():
|
|
87
|
+
"""Remove the .ldpy finder from sys.meta_path."""
|
|
88
|
+
global _finder
|
|
89
|
+
if _finder is not None:
|
|
90
|
+
try:
|
|
91
|
+
sys.meta_path.remove(_finder)
|
|
92
|
+
except ValueError:
|
|
93
|
+
pass
|
|
94
|
+
_finder = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def translate_lineno(path, gen_line_1based):
|
|
98
|
+
"""Generated line (1-based) -> .ldpy source line (1-based), or None."""
|
|
99
|
+
lmap = MAPS.get(path)
|
|
100
|
+
if lmap is None:
|
|
101
|
+
return None
|
|
102
|
+
src = lmap.src_line_for_gen_line(gen_line_1based - 1)
|
|
103
|
+
return None if src is None else src + 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def install_excepthook():
|
|
107
|
+
"""OBSOLETE (kept for compatibility): since the remapped compilation
|
|
108
|
+
(record ldpy/011), code objects already carry the .ldpy line numbers —
|
|
109
|
+
tracebacks are right without rewriting. Does nothing."""
|
|
110
|
+
return sys.excepthook
|
ldpy/lsp/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Language server Linked-Data Python (architecture : docs/explanation/tooling.md).
|
|
2
|
+
|
|
3
|
+
Milestone 2 (implemented): native transpiler diagnostics AND the backend's
|
|
4
|
+
Python diagnostics re-projected, hover (islands natively, the rest delegated),
|
|
5
|
+
semantic tokens for the islands, request forwarding to pylsp for completion,
|
|
6
|
+
definition, references and signatureHelp — positions translated both ways
|
|
7
|
+
through the LanguageMap.
|
|
8
|
+
|
|
9
|
+
Lancement : python -m ldpy.lsp [--backend pylsp|none]
|
|
10
|
+
(the server has no dependency; pylsp is optional, for delegation)
|
|
11
|
+
"""
|
ldpy/lsp/__main__.py
ADDED