entangled-cli 2.4.2__tar.gz → 2.5.0__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.
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/PKG-INFO +31 -14
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/README.md +25 -8
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/__init__.py +2 -1
- entangled_cli-2.5.0/entangled/commands/serve.py +57 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/watch.py +5 -1
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/quarto_attributes.py +13 -2
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/interface/document.py +5 -4
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/io/filedb.py +4 -1
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/io/transaction.py +7 -4
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/iterators/lines.py +2 -5
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/tangle.py +14 -10
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/parsing.py +13 -2
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/readers/code.py +13 -11
- entangled_cli-2.5.0/entangled/server/__init__.py +0 -0
- entangled_cli-2.5.0/entangled/server/server.py +86 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/pyproject.toml +12 -9
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/.gitignore +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/LICENSE +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/brei.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/main.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/new.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/reset.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/status.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/stitch.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/sync.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/commands/tangle.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/annotation_method.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/config_data.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/config_update.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/document_style.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/language.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/markers.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/namespace_default.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/templates.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/config/version.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/errors/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/errors/internal.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/errors/user.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/base.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/build.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/repl.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/shebang.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/spdx_license.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/hooks/task.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/interface/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/interface/context.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/io/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/io/stat.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/io/virtual.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/iterators/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/iterators/peekable.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/iterators/run_generator.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/logging.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/main.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/code_block.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/content.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/properties.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/reference_id.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/reference_map.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/model/reference_name.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/py.typed +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/readers/__init__.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/readers/delimiters.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/readers/markdown.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/readers/types.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/readers/yaml_header.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/status.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/text_location.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/utility.py +0 -0
- {entangled_cli-2.4.2 → entangled_cli-2.5.0}/entangled/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: entangled-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.0
|
|
4
4
|
Summary: Literate Programming toolbox
|
|
5
5
|
Project-URL: Homepage, https://entangled.github.io/
|
|
6
6
|
Project-URL: Repository, https://github.com/entangled/entangled.py
|
|
@@ -11,16 +11,16 @@ Requires-Python: <4,>=3.13
|
|
|
11
11
|
Requires-Dist: brei<0.3,>=0.2.3
|
|
12
12
|
Requires-Dist: click>=8.3.0
|
|
13
13
|
Requires-Dist: copier<10,>=9
|
|
14
|
-
Requires-Dist: filelock<
|
|
14
|
+
Requires-Dist: filelock<5,>=3.12.0
|
|
15
15
|
Requires-Dist: msgspec>=0.20.0
|
|
16
16
|
Requires-Dist: pyyaml<7,>=6.0.1
|
|
17
17
|
Requires-Dist: repl-session>=0.2.0
|
|
18
18
|
Requires-Dist: rich-argparse<2,>=1.4.0
|
|
19
19
|
Requires-Dist: rich-click>=1.9.4
|
|
20
|
-
Requires-Dist: rich<
|
|
21
|
-
Requires-Dist: tomlkit<0.
|
|
20
|
+
Requires-Dist: rich<16,>=13
|
|
21
|
+
Requires-Dist: tomlkit<0.16,>=0.12.1
|
|
22
22
|
Requires-Dist: typeguard>=4.4.4
|
|
23
|
-
Requires-Dist: watchfiles>=1.
|
|
23
|
+
Requires-Dist: watchfiles>=1.3
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
|
|
26
26
|
# Entangled
|
|
@@ -38,24 +38,32 @@ Literate programming was introduced by Donald Knuth in 1984 and has since then f
|
|
|
38
38
|
We’re trying to increase the visibility of Entangled. If you like Entangled, please consider adding this badge [](https://entangled.github.io/) to the appropriate location in your project:
|
|
39
39
|
|
|
40
40
|
```markdown
|
|
41
|
-
[](https://entangled.github.io/)
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
## Get started
|
|
45
45
|
|
|
46
|
-
To install Entangled, all you need is a Python (version ≥3.11) installation. If you use
|
|
46
|
+
To install Entangled, all you need is a Python (version ≥3.11) installation. If you use UV, and you start a new project,
|
|
47
47
|
|
|
48
48
|
```
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
uv init --bare
|
|
50
|
+
uv add entangled-cli
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
The `
|
|
53
|
+
The `uv init --bare` command will create a `pyproject.toml` file and a virtual environment to install Python dependencies in. To activate the virtual environment, run `. ./.venv/bin/activate` inside the project directory, or you can prepend commands with `uv run` to run them in the venv.
|
|
54
54
|
|
|
55
|
-
Or, if you prefer plain old `pip`,
|
|
55
|
+
Or, if you prefer plain old `pip`, or `pipx`,
|
|
56
56
|
|
|
57
57
|
```
|
|
58
|
-
|
|
58
|
+
pipx install entangled-cli
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Here, `pipx` creates a virtual environment for just the `entangled-cli` package.
|
|
62
|
+
|
|
63
|
+
Or, if you prefer the nix package manager:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
nix run github:entangled/entangled.py
|
|
59
67
|
```
|
|
60
68
|
|
|
61
69
|
## Use
|
|
@@ -179,6 +187,13 @@ identifiers = ["java"]
|
|
|
179
187
|
comment = { open = "//" }
|
|
180
188
|
```
|
|
181
189
|
|
|
190
|
+
## Serving Rendered Pages
|
|
191
|
+
Entangled v2.5 includes a auto-reloading web server. Suppose your documents are in `docs` and they are rendered to `site` with `make site`, you can run the live server like so:
|
|
192
|
+
|
|
193
|
+
```shell
|
|
194
|
+
entangled serve --watch docs --build 'entangled sync && make site' site
|
|
195
|
+
```
|
|
196
|
+
|
|
182
197
|
## Working in VSCode
|
|
183
198
|
The [EntangleD-VSCode](https://github.com/deephbz/entangled-vscode) extension provides code block navigation, reference&symbol search, NOWEB high-lighting functionalities.
|
|
184
199
|
|
|
@@ -515,7 +530,9 @@ This is a rewrite of Entangled in Python. Older versions were written in Haskell
|
|
|
515
530
|
|
|
516
531
|
## Contributing
|
|
517
532
|
|
|
518
|
-
If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is
|
|
533
|
+
If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is type checked using `mypy`. Within reasonable limits we adhere to PEP-8 code standards.
|
|
534
|
+
|
|
535
|
+
Some parts of Entangled were developed with AI assistance. When you submit a PR, in interest of careful review, please make sure that you've pruned for needles changes.
|
|
519
536
|
|
|
520
537
|
## License
|
|
521
538
|
|
|
@@ -13,24 +13,32 @@ Literate programming was introduced by Donald Knuth in 1984 and has since then f
|
|
|
13
13
|
We’re trying to increase the visibility of Entangled. If you like Entangled, please consider adding this badge [](https://entangled.github.io/) to the appropriate location in your project:
|
|
14
14
|
|
|
15
15
|
```markdown
|
|
16
|
-
[](https://entangled.github.io/)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
## Get started
|
|
20
20
|
|
|
21
|
-
To install Entangled, all you need is a Python (version ≥3.11) installation. If you use
|
|
21
|
+
To install Entangled, all you need is a Python (version ≥3.11) installation. If you use UV, and you start a new project,
|
|
22
22
|
|
|
23
23
|
```
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
uv init --bare
|
|
25
|
+
uv add entangled-cli
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
The `
|
|
28
|
+
The `uv init --bare` command will create a `pyproject.toml` file and a virtual environment to install Python dependencies in. To activate the virtual environment, run `. ./.venv/bin/activate` inside the project directory, or you can prepend commands with `uv run` to run them in the venv.
|
|
29
29
|
|
|
30
|
-
Or, if you prefer plain old `pip`,
|
|
30
|
+
Or, if you prefer plain old `pip`, or `pipx`,
|
|
31
31
|
|
|
32
32
|
```
|
|
33
|
-
|
|
33
|
+
pipx install entangled-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Here, `pipx` creates a virtual environment for just the `entangled-cli` package.
|
|
37
|
+
|
|
38
|
+
Or, if you prefer the nix package manager:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
nix run github:entangled/entangled.py
|
|
34
42
|
```
|
|
35
43
|
|
|
36
44
|
## Use
|
|
@@ -154,6 +162,13 @@ identifiers = ["java"]
|
|
|
154
162
|
comment = { open = "//" }
|
|
155
163
|
```
|
|
156
164
|
|
|
165
|
+
## Serving Rendered Pages
|
|
166
|
+
Entangled v2.5 includes a auto-reloading web server. Suppose your documents are in `docs` and they are rendered to `site` with `make site`, you can run the live server like so:
|
|
167
|
+
|
|
168
|
+
```shell
|
|
169
|
+
entangled serve --watch docs --build 'entangled sync && make site' site
|
|
170
|
+
```
|
|
171
|
+
|
|
157
172
|
## Working in VSCode
|
|
158
173
|
The [EntangleD-VSCode](https://github.com/deephbz/entangled-vscode) extension provides code block navigation, reference&symbol search, NOWEB high-lighting functionalities.
|
|
159
174
|
|
|
@@ -490,7 +505,9 @@ This is a rewrite of Entangled in Python. Older versions were written in Haskell
|
|
|
490
505
|
|
|
491
506
|
## Contributing
|
|
492
507
|
|
|
493
|
-
If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is
|
|
508
|
+
If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is type checked using `mypy`. Within reasonable limits we adhere to PEP-8 code standards.
|
|
509
|
+
|
|
510
|
+
Some parts of Entangled were developed with AI assistance. When you submit a PR, in interest of careful review, please make sure that you've pruned for needles changes.
|
|
494
511
|
|
|
495
512
|
## License
|
|
496
513
|
|
|
@@ -7,7 +7,8 @@ from .sync import sync
|
|
|
7
7
|
from .watch import watch
|
|
8
8
|
from .status import status
|
|
9
9
|
from .brei import brei
|
|
10
|
+
from .serve import serve
|
|
10
11
|
|
|
11
12
|
__all__ = [
|
|
12
|
-
"main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei"
|
|
13
|
+
"main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei", "serve"
|
|
13
14
|
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from ..server.server import Reloader, Handler
|
|
2
|
+
from .main import main
|
|
3
|
+
from ..logging import logger
|
|
4
|
+
|
|
5
|
+
from functools import partial
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
from http.server import ThreadingHTTPServer
|
|
10
|
+
|
|
11
|
+
from watchfiles import watch
|
|
12
|
+
import click
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def watch_site(site: Path, reloader: Reloader):
|
|
16
|
+
for changes in watch(site):
|
|
17
|
+
logger().info(f"[livereload] {len(changes)} change(s) in {site}, reloading")
|
|
18
|
+
reloader.trigger()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def watch_sources(paths: list[Path], site: Path, build: str):
|
|
22
|
+
def is_source(_change, path: str) -> bool:
|
|
23
|
+
return not Path(path).resolve().is_relative_to(site.resolve())
|
|
24
|
+
|
|
25
|
+
for _ in watch(*paths, watch_filter=is_source):
|
|
26
|
+
logger().info(f"[livereload] sources changed, running: {build}")
|
|
27
|
+
subprocess.run(build, shell=True, check=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@main.command()
|
|
31
|
+
@click.argument("directory", type=Path, help="directory to serve")
|
|
32
|
+
@click.option("-p", "--port", type=int, default=8000)
|
|
33
|
+
@click.option("-b", "--bind", default="127.0.0.1")
|
|
34
|
+
@click.option("--build", help="shell command to run when sources change")
|
|
35
|
+
@click.option("-w", "--watch", type=Path, multiple=True, help="source paths for --build")
|
|
36
|
+
def serve(directory: Path, port: int, bind: str, build: str | None = None, watch: list[Path] | None = None) -> None:
|
|
37
|
+
"""Stand-alone webserver with browser auto-reloading."""
|
|
38
|
+
reloader = Reloader()
|
|
39
|
+
threading.Thread(
|
|
40
|
+
target=watch_site, args=(directory, reloader), daemon=True
|
|
41
|
+
).start()
|
|
42
|
+
if build:
|
|
43
|
+
threading.Thread(
|
|
44
|
+
target=watch_sources,
|
|
45
|
+
args=(watch or [Path(".")], directory, build),
|
|
46
|
+
daemon=True,
|
|
47
|
+
).start()
|
|
48
|
+
|
|
49
|
+
handler = partial(Handler, directory=str(directory))
|
|
50
|
+
Handler.reloader = reloader
|
|
51
|
+
with ThreadingHTTPServer((bind, port), handler) as server:
|
|
52
|
+
server.daemon_threads = True
|
|
53
|
+
logger().info(f"[livereload] serving {directory} at http://{bind}:{port}/")
|
|
54
|
+
try:
|
|
55
|
+
server.serve_forever()
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
pass
|
|
@@ -6,6 +6,7 @@ from ..logging import logger
|
|
|
6
6
|
|
|
7
7
|
from .sync import run_sync
|
|
8
8
|
from .main import main
|
|
9
|
+
from ..errors.user import UserError
|
|
9
10
|
|
|
10
11
|
import watchfiles
|
|
11
12
|
|
|
@@ -38,7 +39,10 @@ def _watch(_stop_event: Event | None = None, _start_event: Event | None = None):
|
|
|
38
39
|
|
|
39
40
|
for changes in watchfiles.watch(dirs, stop_event=_stop_event, watch_filter=watch_filter):
|
|
40
41
|
log.debug(changes)
|
|
41
|
-
|
|
42
|
+
try:
|
|
43
|
+
run_sync()
|
|
44
|
+
except UserError as e:
|
|
45
|
+
logger().error(e, exc_info=False)
|
|
42
46
|
|
|
43
47
|
|
|
44
48
|
@main.command()
|
|
@@ -14,16 +14,27 @@ from ..logging import logger
|
|
|
14
14
|
|
|
15
15
|
log = logger()
|
|
16
16
|
|
|
17
|
+
# Cache for compiled regex patterns (keyed by comment opener)
|
|
18
|
+
_yaml_header_pattern_cache: dict[str, re.Pattern[str]] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_yaml_header_pattern(comment_open: str) -> re.Pattern[str]:
|
|
22
|
+
"""Get or create a cached compiled pattern for YAML header matching."""
|
|
23
|
+
if comment_open not in _yaml_header_pattern_cache:
|
|
24
|
+
pattern = re.escape(comment_open) + r"\s*\|(.*)"
|
|
25
|
+
_yaml_header_pattern_cache[comment_open] = re.compile(pattern)
|
|
26
|
+
return _yaml_header_pattern_cache[comment_open]
|
|
27
|
+
|
|
17
28
|
|
|
18
29
|
def split_yaml_header(language: Language, source: str) -> tuple[str, str, object]:
|
|
19
30
|
"""Split source into YAML header and body."""
|
|
20
|
-
|
|
31
|
+
pattern = _get_yaml_header_pattern(language.comment.open)
|
|
21
32
|
lines = source.splitlines(keepends=True)
|
|
22
33
|
header_lines: list[str] = []
|
|
23
34
|
body_start: int = 0
|
|
24
35
|
|
|
25
36
|
for i, line in enumerate(lines):
|
|
26
|
-
if m :=
|
|
37
|
+
if m := pattern.match(line):
|
|
27
38
|
header_lines.append(m.group(1))
|
|
28
39
|
continue
|
|
29
40
|
|
|
@@ -35,14 +35,15 @@ class Document:
|
|
|
35
35
|
return get_input_files(self.context.fs, self.config)
|
|
36
36
|
|
|
37
37
|
def source_text(self, path: Path) -> tuple[str, set[PurePath]]:
|
|
38
|
-
deps = set()
|
|
39
|
-
|
|
38
|
+
deps: set[PurePath] = set()
|
|
39
|
+
# Use list for O(n) instead of O(n²) string concatenation
|
|
40
|
+
text_parts: list[str] = []
|
|
40
41
|
for content in self.content[path]:
|
|
41
42
|
t, d = content_to_text(self.reference_map, content)
|
|
42
43
|
if d is not None:
|
|
43
44
|
deps.add(d)
|
|
44
|
-
|
|
45
|
-
return
|
|
45
|
+
text_parts.append(t)
|
|
46
|
+
return "".join(text_parts), deps
|
|
46
47
|
|
|
47
48
|
def target_text(self, path: PurePath) -> tuple[str, set[PurePath]]:
|
|
48
49
|
ref_name = self.reference_map.select_by_target(path)
|
|
@@ -50,8 +50,11 @@ class FileDB(Struct):
|
|
|
50
50
|
return {Path(p) for p in self.targets}
|
|
51
51
|
|
|
52
52
|
def changed_files(self, fs: AbstractFileCache) -> Generator[Path]:
|
|
53
|
+
# A tracked file that no longer exists (e.g. a source that was moved or
|
|
54
|
+
# deleted) counts as changed. Without this guard `fs[Path(p)]` would
|
|
55
|
+
# raise `FileNotFoundError` and crash, see issue #88.
|
|
53
56
|
return (Path(p) for p, known_stat in self.files.items()
|
|
54
|
-
if fs[Path(p)].stat != known_stat)
|
|
57
|
+
if Path(p) not in fs or fs[Path(p)].stat != known_stat)
|
|
55
58
|
|
|
56
59
|
def create_target(self, fs: AbstractFileCache, path: Path):
|
|
57
60
|
if path.is_absolute():
|
|
@@ -93,12 +93,15 @@ class Write(WriterBase):
|
|
|
93
93
|
def conflict(self, fs: AbstractFileCache, db: FileDB) -> Conflict | None:
|
|
94
94
|
if self.target not in fs:
|
|
95
95
|
return None
|
|
96
|
+
# If the file on disk matches the filedb record, it is exactly what
|
|
97
|
+
# Entangled wrote last time and was not edited outside of Entangled, so
|
|
98
|
+
# it is safe to overwrite. Comparing against the filedb is more reliable
|
|
99
|
+
# than comparing modification times against the current set of sources:
|
|
100
|
+
# the latter gives false positives when the dependency graph changes,
|
|
101
|
+
# e.g. when a target no longer depends on one of its former sources
|
|
102
|
+
# (see issue #96).
|
|
96
103
|
if fs[self.target].stat != db[self.target]:
|
|
97
104
|
return Conflict(self.target, "changed outside the control of Entangled")
|
|
98
|
-
if self.sources:
|
|
99
|
-
if all(fs[s].stat < fs[self.target].stat for s in self.sources):
|
|
100
|
-
return Conflict(self.target, "newer than all of its sources: " + ", ".join(
|
|
101
|
-
f"`{s}`" for s in set(self.sources)))
|
|
102
105
|
return None
|
|
103
106
|
|
|
104
107
|
@override
|
|
@@ -10,19 +10,16 @@ type InputToken = tuple[TextLocation, str]
|
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def lines(text: str) -> Generator[str]:
|
|
13
|
+
"""Iterate over lines in text, preserving newlines."""
|
|
13
14
|
pos = 0
|
|
14
15
|
while (next_pos := text.find("\n", pos)) != -1:
|
|
15
16
|
yield text[pos:next_pos + 1]
|
|
16
17
|
pos = next_pos + 1
|
|
17
|
-
|
|
18
18
|
yield text[pos:]
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
@peekable
|
|
22
22
|
def numbered_lines(filename: PurePath, text: str) -> Generator[InputToken]:
|
|
23
|
-
"""
|
|
24
|
-
Iterate the lines in a file. Doesn't strip newlines. Works with both
|
|
25
|
-
Windows and Unix line endings.
|
|
26
|
-
"""
|
|
23
|
+
"""Iterate the lines in a file. Doesn't strip newlines."""
|
|
27
24
|
for n, line in enumerate(lines(text)):
|
|
28
25
|
yield (TextLocation(filename, n+1), line)
|
|
@@ -4,7 +4,6 @@ from contextlib import contextmanager
|
|
|
4
4
|
from pathlib import PurePath
|
|
5
5
|
|
|
6
6
|
import re
|
|
7
|
-
import os
|
|
8
7
|
from typing import override
|
|
9
8
|
|
|
10
9
|
|
|
@@ -21,6 +20,10 @@ from .reference_name import ReferenceName
|
|
|
21
20
|
|
|
22
21
|
log = logger()
|
|
23
22
|
|
|
23
|
+
# Pre-compiled regex for reference detection (e.g., " <<refname>>")
|
|
24
|
+
_REF_PATTERN = re.compile(r"^(?P<indent>\s*)<<(?P<refname>[\w:/_.-]+)>>\s*$")
|
|
25
|
+
|
|
26
|
+
|
|
24
27
|
@dataclass
|
|
25
28
|
class CyclicReference(UserError):
|
|
26
29
|
ref_name: str
|
|
@@ -89,7 +92,7 @@ def naked_tangler(refs: ReferenceMap) -> Tangler:
|
|
|
89
92
|
|
|
90
93
|
with visitor.visit(ref):
|
|
91
94
|
for line in lines(code_block.source):
|
|
92
|
-
if m :=
|
|
95
|
+
if m := _REF_PATTERN.match(line.rstrip()):
|
|
93
96
|
ref_name = ReferenceName.from_str(m["refname"], code_block.namespace)
|
|
94
97
|
log.debug(f"tangling reference `{ref_name}`")
|
|
95
98
|
if not refs.has_name(ref_name):
|
|
@@ -146,13 +149,14 @@ def tangle_ref(
|
|
|
146
149
|
raise KeyError(name)
|
|
147
150
|
tangler = tanglers[annotation](refs)
|
|
148
151
|
deps: set[PurePath] = set()
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
152
|
+
|
|
153
|
+
def all_lines():
|
|
154
|
+
ref_lst = refs.select_by_name(name)
|
|
155
|
+
yield from tangler(tangler, deps, ref_lst[0], False, True)
|
|
156
|
+
for ref in ref_lst[1:]:
|
|
157
|
+
yield from tangler(tangler, deps, ref, False, False)
|
|
158
|
+
|
|
159
|
+
# Use join for O(n) instead of O(n²) string concatenation
|
|
160
|
+
out = "".join(all_lines())
|
|
157
161
|
|
|
158
162
|
return out, deps
|
|
@@ -14,6 +14,17 @@ from typing import (
|
|
|
14
14
|
import re
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
# Cache for compiled regex patterns (avoids re-compilation)
|
|
18
|
+
_pattern_cache: dict[str, re.Pattern[str]] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _cached_pattern(regex: str) -> re.Pattern[str]:
|
|
22
|
+
"""Get or create a cached compiled pattern."""
|
|
23
|
+
if regex not in _pattern_cache:
|
|
24
|
+
_pattern_cache[regex] = re.compile(f"^{regex}")
|
|
25
|
+
return _pattern_cache[regex]
|
|
26
|
+
|
|
27
|
+
|
|
17
28
|
@dataclass
|
|
18
29
|
class Failure(Exception):
|
|
19
30
|
"""Base class for parser failures."""
|
|
@@ -198,7 +209,7 @@ def many[T](p: Parser[T]) -> Parser[list[T]]:
|
|
|
198
209
|
|
|
199
210
|
|
|
200
211
|
def matching(regex: str) -> Parser[tuple[str, ...]]:
|
|
201
|
-
pattern =
|
|
212
|
+
pattern = _cached_pattern(regex)
|
|
202
213
|
|
|
203
214
|
@parser
|
|
204
215
|
def _matching(inp: str) -> tuple[tuple[str, ...], str]:
|
|
@@ -210,7 +221,7 @@ def matching(regex: str) -> Parser[tuple[str, ...]]:
|
|
|
210
221
|
|
|
211
222
|
|
|
212
223
|
def fullmatch(regex: str) -> Parser[str]:
|
|
213
|
-
pattern =
|
|
224
|
+
pattern = _cached_pattern(regex)
|
|
214
225
|
|
|
215
226
|
@parser
|
|
216
227
|
def _fullmatch(inp: str):
|
|
@@ -17,7 +17,11 @@ class Block:
|
|
|
17
17
|
content: str
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# Pre-compile regex patterns at module level (avoid re-compilation on every call)
|
|
21
|
+
_OPEN_BLOCK_PATTERN = re.compile(
|
|
22
|
+
r"^(?P<indent>\s*).* ~/~ begin <<(?P<source>[^#<>]+)#(?P<ref_name>[^#<>]+)>>\[(?P<ref_count>\d+|init)\]"
|
|
23
|
+
)
|
|
24
|
+
_CLOSE_BLOCK_PATTERN = re.compile(r"^(?P<indent>\s*).* ~/~ end")
|
|
21
25
|
|
|
22
26
|
|
|
23
27
|
@dataclass
|
|
@@ -28,7 +32,7 @@ class OpenBlockData:
|
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
def open_block(line: str) -> OpenBlockData | None:
|
|
31
|
-
if not (m :=
|
|
35
|
+
if not (m := _OPEN_BLOCK_PATTERN.match(line)):
|
|
32
36
|
return None
|
|
33
37
|
|
|
34
38
|
ref_name = ReferenceName.from_str(m["ref_name"])
|
|
@@ -38,16 +42,13 @@ def open_block(line: str) -> OpenBlockData | None:
|
|
|
38
42
|
return OpenBlockData(ReferenceId(ref_name, md_source, ref_count), is_init, m["indent"])
|
|
39
43
|
|
|
40
44
|
|
|
41
|
-
CLOSE_BLOCK_EXPR = r"^(?P<indent>\s*).* ~/~ end"
|
|
42
|
-
|
|
43
|
-
|
|
44
45
|
@dataclass
|
|
45
46
|
class CloseBlockData:
|
|
46
47
|
indent: str
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
def close_block(line: str) -> CloseBlockData | None:
|
|
50
|
-
if not (m :=
|
|
51
|
+
if not (m := _CLOSE_BLOCK_PATTERN.match(line)):
|
|
51
52
|
return None
|
|
52
53
|
return CloseBlockData(m["indent"])
|
|
53
54
|
|
|
@@ -76,25 +77,26 @@ def read_block(namespace: tuple[str, ...], indent: str, input: InputStream) -> G
|
|
|
76
77
|
if block_data.indent < indent:
|
|
77
78
|
raise IndentationError(pos)
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
# Use list for O(n) instead of O(n²) string concatenation
|
|
81
|
+
content_parts: list[str] = []
|
|
80
82
|
while input:
|
|
81
83
|
line = yield from read_block(block_data.ref.name.namespace, block_data.indent, input)
|
|
82
84
|
if line is not None:
|
|
83
|
-
|
|
85
|
+
content_parts.append(line)
|
|
84
86
|
continue
|
|
85
87
|
|
|
86
88
|
pos, line = next(input)
|
|
87
89
|
if (close_block_data := close_block(line)) is None:
|
|
88
90
|
if not line.strip():
|
|
89
|
-
|
|
91
|
+
content_parts.append(line.lstrip(" \t"))
|
|
90
92
|
elif not line.startswith(block_data.indent):
|
|
91
93
|
raise IndentationError(pos)
|
|
92
94
|
else:
|
|
93
|
-
|
|
95
|
+
content_parts.append(line.removeprefix(block_data.indent))
|
|
94
96
|
else:
|
|
95
97
|
if close_block_data.indent != block_data.indent:
|
|
96
98
|
raise IndentationError(pos)
|
|
97
|
-
yield Block(block_data.ref,
|
|
99
|
+
yield Block(block_data.ref, "".join(content_parts))
|
|
98
100
|
|
|
99
101
|
if block_data.is_init:
|
|
100
102
|
extra_indent = block_data.indent.removeprefix(indent)
|
|
File without changes
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Minimal live-reloading static file server.
|
|
2
|
+
|
|
3
|
+
Serves a directory over HTTP, injects a tiny script into every HTML page,
|
|
4
|
+
and tells connected browsers to reload (via Server-Sent Events) whenever
|
|
5
|
+
something in the served directory changes. Optionally runs a build command
|
|
6
|
+
whenever source files change.
|
|
7
|
+
|
|
8
|
+
Mostly generated by Claude Opus 5.5.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from http import HTTPStatus
|
|
14
|
+
from http.server import SimpleHTTPRequestHandler
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
EVENT_PATH = "/__livereload"
|
|
18
|
+
SNIPPET = (
|
|
19
|
+
f'<script>new EventSource("{EVENT_PATH}")'
|
|
20
|
+
'.addEventListener("reload", () => location.reload());</script>'
|
|
21
|
+
).encode()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Reloader:
|
|
25
|
+
"""Generation counter that SSE handlers block on."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.generation = 0
|
|
29
|
+
self.cond = threading.Condition()
|
|
30
|
+
|
|
31
|
+
def trigger(self):
|
|
32
|
+
with self.cond:
|
|
33
|
+
self.generation += 1
|
|
34
|
+
self.cond.notify_all()
|
|
35
|
+
|
|
36
|
+
def wait(self, generation: int, timeout: float) -> int:
|
|
37
|
+
with self.cond:
|
|
38
|
+
self.cond.wait_for(lambda: self.generation != generation, timeout)
|
|
39
|
+
return self.generation
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Handler(SimpleHTTPRequestHandler):
|
|
43
|
+
reloader: Reloader
|
|
44
|
+
|
|
45
|
+
def do_GET(self):
|
|
46
|
+
if self.path == EVENT_PATH:
|
|
47
|
+
return self.serve_events()
|
|
48
|
+
path = Path(self.translate_path(self.path))
|
|
49
|
+
if path.is_dir():
|
|
50
|
+
path = path / "index.html"
|
|
51
|
+
if path.suffix == ".html" and path.is_file():
|
|
52
|
+
return self.serve_html(path)
|
|
53
|
+
return super().do_GET()
|
|
54
|
+
|
|
55
|
+
def serve_html(self, path: Path):
|
|
56
|
+
body = path.read_bytes()
|
|
57
|
+
i = body.rfind(b"</body>")
|
|
58
|
+
body = body[:i] + SNIPPET + body[i:] if i >= 0 else body + SNIPPET
|
|
59
|
+
self.send_response(HTTPStatus.OK)
|
|
60
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
61
|
+
self.send_header("Content-Length", str(len(body)))
|
|
62
|
+
self.send_header("Cache-Control", "no-store")
|
|
63
|
+
self.end_headers()
|
|
64
|
+
self.wfile.write(body)
|
|
65
|
+
|
|
66
|
+
def serve_events(self):
|
|
67
|
+
self.send_response(HTTPStatus.OK)
|
|
68
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
69
|
+
self.send_header("Cache-Control", "no-store")
|
|
70
|
+
self.end_headers()
|
|
71
|
+
generation = self.reloader.generation
|
|
72
|
+
try:
|
|
73
|
+
while True:
|
|
74
|
+
new = self.reloader.wait(generation, timeout=15)
|
|
75
|
+
if new != generation:
|
|
76
|
+
generation = new
|
|
77
|
+
self.wfile.write(b"event: reload\ndata: \n\n")
|
|
78
|
+
else:
|
|
79
|
+
self.wfile.write(b": keepalive\n\n") # detects closed tabs
|
|
80
|
+
self.wfile.flush()
|
|
81
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
def log_message(self, format, *args):
|
|
85
|
+
if self.path != EVENT_PATH:
|
|
86
|
+
super().log_message(format, *args)
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "entangled-cli"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.5.0"
|
|
4
4
|
description = "Literate Programming toolbox"
|
|
5
5
|
authors = [{ name = "Johan Hidding", email = "j.hidding@esciencecenter.nl" }]
|
|
6
6
|
requires-python = ">=3.13,<4"
|
|
7
7
|
readme = "README.md"
|
|
8
8
|
license = "Apache-2.0"
|
|
9
9
|
dependencies = [
|
|
10
|
-
"filelock>=3.12.0,<
|
|
11
|
-
"rich>=13,<
|
|
12
|
-
"tomlkit>=0.12.1,<0.
|
|
10
|
+
"filelock>=3.12.0,<5",
|
|
11
|
+
"rich>=13,<16",
|
|
12
|
+
"tomlkit>=0.12.1,<0.16",
|
|
13
13
|
"copier>=9,<10",
|
|
14
14
|
"brei>=0.2.3,<0.3",
|
|
15
15
|
"rich-argparse>=1.4.0,<2",
|
|
@@ -19,7 +19,7 @@ dependencies = [
|
|
|
19
19
|
"click>=8.3.0",
|
|
20
20
|
"rich-click>=1.9.4",
|
|
21
21
|
"typeguard>=4.4.4",
|
|
22
|
-
"watchfiles>=1.
|
|
22
|
+
"watchfiles>=1.3",
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
[project.urls]
|
|
@@ -32,8 +32,7 @@ entangled = "entangled.main:cli"
|
|
|
32
32
|
[dependency-groups]
|
|
33
33
|
dev = [
|
|
34
34
|
"pytest>=9.0,<10",
|
|
35
|
-
"mypy>=
|
|
36
|
-
"black>=23.3.0,<24",
|
|
35
|
+
"mypy>=2.4.0",
|
|
37
36
|
"pytest-cov>=4.0.0,<5",
|
|
38
37
|
"mkdocs>=1.4.3,<2",
|
|
39
38
|
"mkdocs-material>=9.7,<10",
|
|
@@ -48,7 +47,6 @@ dev = [
|
|
|
48
47
|
"types-pexpect>=4.9.0.20250916",
|
|
49
48
|
"types-PyYAML>=6.0.1,<7",
|
|
50
49
|
"pytest-timeout>=2.4.0",
|
|
51
|
-
"ty>=0.0.1a24",
|
|
52
50
|
]
|
|
53
51
|
|
|
54
52
|
[tool.hatch.build.targets.sdist]
|
|
@@ -70,5 +68,10 @@ testpaths = ["test"]
|
|
|
70
68
|
[tool.mypy]
|
|
71
69
|
packages = ["entangled"]
|
|
72
70
|
|
|
71
|
+
[tool.uv.workspace]
|
|
72
|
+
members = [
|
|
73
|
+
".",
|
|
74
|
+
]
|
|
75
|
+
|
|
73
76
|
[tool.uv.sources]
|
|
74
|
-
|
|
77
|
+
entangled-cli = { workspace = true, editable = true }
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|