entangled-cli 2.4.3__tar.gz → 2.6.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.3/README.md → entangled_cli-2.6.0/PKG-INFO +62 -8
- entangled_cli-2.4.3/PKG-INFO → entangled_cli-2.6.0/README.md +35 -33
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/__init__.py +3 -1
- entangled_cli-2.6.0/entangled/commands/serve.py +57 -0
- entangled_cli-2.6.0/entangled/commands/weave.py +50 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/errors/user.py +12 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/repl.py +13 -3
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/interface/document.py +25 -5
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/stat.py +7 -4
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/logging.py +1 -1
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/tangle.py +3 -1
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/markdown.py +5 -1
- entangled_cli-2.6.0/entangled/server/__init__.py +0 -0
- entangled_cli-2.6.0/entangled/server/server.py +86 -0
- entangled_cli-2.6.0/entangled/weave/__init__.py +9 -0
- entangled_cli-2.6.0/entangled/weave/base.py +66 -0
- entangled_cli-2.6.0/entangled/weave/filters.py +235 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/pyproject.toml +19 -10
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/.gitignore +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/LICENSE +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/brei.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/main.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/new.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/reset.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/status.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/stitch.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/sync.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/tangle.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/watch.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/annotation_method.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/config_data.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/config_update.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/document_style.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/language.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/markers.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/namespace_default.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/templates.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/version.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/errors/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/errors/internal.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/base.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/build.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/quarto_attributes.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/shebang.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/spdx_license.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/task.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/interface/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/interface/context.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/filedb.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/transaction.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/virtual.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/lines.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/peekable.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/run_generator.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/main.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/code_block.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/content.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/properties.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/reference_id.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/reference_map.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/reference_name.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/parsing.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/py.typed +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/__init__.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/code.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/delimiters.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/types.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/yaml_header.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/status.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/text_location.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/utility.py +0 -0
- {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/version.py +0 -0
|
@@ -1,3 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: entangled-cli
|
|
3
|
+
Version: 2.6.0
|
|
4
|
+
Summary: Literate Programming toolbox
|
|
5
|
+
Project-URL: Homepage, https://entangled.github.io/
|
|
6
|
+
Project-URL: Repository, https://github.com/entangled/entangled.py
|
|
7
|
+
Author-email: Johan Hidding <j.hidding@esciencecenter.nl>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: <4,>=3.13
|
|
11
|
+
Requires-Dist: brei<0.3,>=0.2.3
|
|
12
|
+
Requires-Dist: click>=8.3.0
|
|
13
|
+
Requires-Dist: copier<10,>=9
|
|
14
|
+
Requires-Dist: filelock<5,>=3.12.0
|
|
15
|
+
Requires-Dist: msgspec>=0.20.0
|
|
16
|
+
Requires-Dist: pyyaml<7,>=6.0.1
|
|
17
|
+
Requires-Dist: repl-session>=0.3.0
|
|
18
|
+
Requires-Dist: rich-argparse<2,>=1.4.0
|
|
19
|
+
Requires-Dist: rich-click>=1.9.4
|
|
20
|
+
Requires-Dist: rich<16,>=13
|
|
21
|
+
Requires-Dist: tomlkit<0.16,>=0.12.1
|
|
22
|
+
Requires-Dist: typeguard>=4.4.4
|
|
23
|
+
Requires-Dist: watchfiles>=1.3
|
|
24
|
+
Provides-Extra: jupyter
|
|
25
|
+
Requires-Dist: repl-session[jupyter]>=0.3.0; extra == 'jupyter'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
1
28
|
# Entangled
|
|
2
29
|
|
|
3
30
|
[](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml)
|
|
@@ -13,26 +40,28 @@ Literate programming was introduced by Donald Knuth in 1984 and has since then f
|
|
|
13
40
|
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
41
|
|
|
15
42
|
```markdown
|
|
16
|
-
[](https://entangled.github.io/)
|
|
17
44
|
```
|
|
18
45
|
|
|
19
46
|
## Get started
|
|
20
47
|
|
|
21
|
-
To install Entangled, all you need is a Python (version ≥3.11) installation. If you use
|
|
48
|
+
To install Entangled, all you need is a Python (version ≥3.11) installation. If you use UV, and you start a new project,
|
|
22
49
|
|
|
23
50
|
```
|
|
24
|
-
|
|
25
|
-
|
|
51
|
+
uv init --bare
|
|
52
|
+
uv add entangled-cli
|
|
26
53
|
```
|
|
27
54
|
|
|
28
|
-
The `
|
|
55
|
+
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
56
|
|
|
30
|
-
Or, if you prefer plain old `pip`,
|
|
57
|
+
Or, if you prefer plain old `pip`, or `pipx`,
|
|
31
58
|
|
|
32
59
|
```
|
|
33
|
-
|
|
60
|
+
pipx install entangled-cli
|
|
34
61
|
```
|
|
35
62
|
|
|
63
|
+
Here, `pipx` creates a virtual environment for just the `entangled-cli` package.
|
|
64
|
+
|
|
36
65
|
Or, if you prefer the nix package manager:
|
|
37
66
|
|
|
38
67
|
```
|
|
@@ -160,6 +189,13 @@ identifiers = ["java"]
|
|
|
160
189
|
comment = { open = "//" }
|
|
161
190
|
```
|
|
162
191
|
|
|
192
|
+
## Serving Rendered Pages
|
|
193
|
+
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:
|
|
194
|
+
|
|
195
|
+
```shell
|
|
196
|
+
entangled serve --watch docs --build 'entangled sync && make site' site
|
|
197
|
+
```
|
|
198
|
+
|
|
163
199
|
## Working in VSCode
|
|
164
200
|
The [EntangleD-VSCode](https://github.com/deephbz/entangled-vscode) extension provides code block navigation, reference&symbol search, NOWEB high-lighting functionalities.
|
|
165
201
|
|
|
@@ -431,6 +467,22 @@ You can use `${variable}` syntax inside Brei tasks just as you would in a stand-
|
|
|
431
467
|
|
|
432
468
|
Entangled has been used successfully with the following document generators. Note that some of these examples were built using older versions of Entangled, but they should work just the same.
|
|
433
469
|
|
|
470
|
+
### Weaving
|
|
471
|
+
|
|
472
|
+
The `entangled weave` command transforms a Markdown document into Markdown that renders well in a documentation tool, by applying one or more filters to the code blocks:
|
|
473
|
+
|
|
474
|
+
```shell
|
|
475
|
+
entangled weave --list-filters # show available filters
|
|
476
|
+
entangled weave docs/index.md # apply the `label` filter, print to stdout
|
|
477
|
+
entangled weave -f title docs/index.md -o out/index.md # MkDocs/pymdownx style titles
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
- `label` wraps each named code block in a `<div class="entangled-code-block">` with a label showing its name or target file. This renders in most Markdown previews (GitHub, Pandoc, Python-Markdown with `md_in_html`).
|
|
481
|
+
- `title` adds a `title` attribute to the code block header, as understood by `pymdownx.superfences`.
|
|
482
|
+
- `repl` inserts the results of a REPL session (see the `repl` hook) after each code block with the `repl` class. The results are read from the session file with its extension replaced by `.out.json`, e.g. `repl-session < session.json > session.out.json`. Text output becomes a `{.text .output}` code block; with the Jupyter backend, rich output (Markdown, HTML, SVG, PNG/JPEG) is included as well, with images embedded as data URIs. Set a `mime-type` attribute on the code block to choose a representation.
|
|
483
|
+
|
|
484
|
+
Filters can be combined by repeating `-f`; they are applied in the given order. The same transformation is available from Python through `entangled.weave.transform_markdown`, so that documentation tools only need a thin adapter.
|
|
485
|
+
|
|
434
486
|
### Pandoc
|
|
435
487
|
|
|
436
488
|
[Pandoc](https://pandoc.org/) is a very versatile tool for converting documents in any format. It specifically has very wide support for different forms of Markdown syntax out in the wild, including a filter system that lets you extend the workings of Pandoc. Those filters can be written in any language through an API, for instance Python filters can be written using `panflute`, but there is also native support for Lua.
|
|
@@ -496,7 +548,9 @@ This is a rewrite of Entangled in Python. Older versions were written in Haskell
|
|
|
496
548
|
|
|
497
549
|
## Contributing
|
|
498
550
|
|
|
499
|
-
If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is
|
|
551
|
+
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.
|
|
552
|
+
|
|
553
|
+
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.
|
|
500
554
|
|
|
501
555
|
## License
|
|
502
556
|
|
|
@@ -1,28 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: entangled-cli
|
|
3
|
-
Version: 2.4.3
|
|
4
|
-
Summary: Literate Programming toolbox
|
|
5
|
-
Project-URL: Homepage, https://entangled.github.io/
|
|
6
|
-
Project-URL: Repository, https://github.com/entangled/entangled.py
|
|
7
|
-
Author-email: Johan Hidding <j.hidding@esciencecenter.nl>
|
|
8
|
-
License-Expression: Apache-2.0
|
|
9
|
-
License-File: LICENSE
|
|
10
|
-
Requires-Python: <4,>=3.13
|
|
11
|
-
Requires-Dist: brei<0.3,>=0.2.3
|
|
12
|
-
Requires-Dist: click>=8.3.0
|
|
13
|
-
Requires-Dist: copier<10,>=9
|
|
14
|
-
Requires-Dist: filelock<4,>=3.12.0
|
|
15
|
-
Requires-Dist: msgspec>=0.20.0
|
|
16
|
-
Requires-Dist: pyyaml<7,>=6.0.1
|
|
17
|
-
Requires-Dist: repl-session>=0.2.0
|
|
18
|
-
Requires-Dist: rich-argparse<2,>=1.4.0
|
|
19
|
-
Requires-Dist: rich-click>=1.9.4
|
|
20
|
-
Requires-Dist: rich<15,>=13
|
|
21
|
-
Requires-Dist: tomlkit<0.13,>=0.12.1
|
|
22
|
-
Requires-Dist: typeguard>=4.4.4
|
|
23
|
-
Requires-Dist: watchfiles>=1.1.1
|
|
24
|
-
Description-Content-Type: text/markdown
|
|
25
|
-
|
|
26
1
|
# Entangled
|
|
27
2
|
|
|
28
3
|
[](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml)
|
|
@@ -38,26 +13,28 @@ Literate programming was introduced by Donald Knuth in 1984 and has since then f
|
|
|
38
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:
|
|
39
14
|
|
|
40
15
|
```markdown
|
|
41
|
-
[](https://entangled.github.io/)
|
|
42
17
|
```
|
|
43
18
|
|
|
44
19
|
## Get started
|
|
45
20
|
|
|
46
|
-
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,
|
|
47
22
|
|
|
48
23
|
```
|
|
49
|
-
|
|
50
|
-
|
|
24
|
+
uv init --bare
|
|
25
|
+
uv add entangled-cli
|
|
51
26
|
```
|
|
52
27
|
|
|
53
|
-
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.
|
|
54
29
|
|
|
55
|
-
Or, if you prefer plain old `pip`,
|
|
30
|
+
Or, if you prefer plain old `pip`, or `pipx`,
|
|
56
31
|
|
|
57
32
|
```
|
|
58
|
-
|
|
33
|
+
pipx install entangled-cli
|
|
59
34
|
```
|
|
60
35
|
|
|
36
|
+
Here, `pipx` creates a virtual environment for just the `entangled-cli` package.
|
|
37
|
+
|
|
61
38
|
Or, if you prefer the nix package manager:
|
|
62
39
|
|
|
63
40
|
```
|
|
@@ -185,6 +162,13 @@ identifiers = ["java"]
|
|
|
185
162
|
comment = { open = "//" }
|
|
186
163
|
```
|
|
187
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
|
+
|
|
188
172
|
## Working in VSCode
|
|
189
173
|
The [EntangleD-VSCode](https://github.com/deephbz/entangled-vscode) extension provides code block navigation, reference&symbol search, NOWEB high-lighting functionalities.
|
|
190
174
|
|
|
@@ -456,6 +440,22 @@ You can use `${variable}` syntax inside Brei tasks just as you would in a stand-
|
|
|
456
440
|
|
|
457
441
|
Entangled has been used successfully with the following document generators. Note that some of these examples were built using older versions of Entangled, but they should work just the same.
|
|
458
442
|
|
|
443
|
+
### Weaving
|
|
444
|
+
|
|
445
|
+
The `entangled weave` command transforms a Markdown document into Markdown that renders well in a documentation tool, by applying one or more filters to the code blocks:
|
|
446
|
+
|
|
447
|
+
```shell
|
|
448
|
+
entangled weave --list-filters # show available filters
|
|
449
|
+
entangled weave docs/index.md # apply the `label` filter, print to stdout
|
|
450
|
+
entangled weave -f title docs/index.md -o out/index.md # MkDocs/pymdownx style titles
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
- `label` wraps each named code block in a `<div class="entangled-code-block">` with a label showing its name or target file. This renders in most Markdown previews (GitHub, Pandoc, Python-Markdown with `md_in_html`).
|
|
454
|
+
- `title` adds a `title` attribute to the code block header, as understood by `pymdownx.superfences`.
|
|
455
|
+
- `repl` inserts the results of a REPL session (see the `repl` hook) after each code block with the `repl` class. The results are read from the session file with its extension replaced by `.out.json`, e.g. `repl-session < session.json > session.out.json`. Text output becomes a `{.text .output}` code block; with the Jupyter backend, rich output (Markdown, HTML, SVG, PNG/JPEG) is included as well, with images embedded as data URIs. Set a `mime-type` attribute on the code block to choose a representation.
|
|
456
|
+
|
|
457
|
+
Filters can be combined by repeating `-f`; they are applied in the given order. The same transformation is available from Python through `entangled.weave.transform_markdown`, so that documentation tools only need a thin adapter.
|
|
458
|
+
|
|
459
459
|
### Pandoc
|
|
460
460
|
|
|
461
461
|
[Pandoc](https://pandoc.org/) is a very versatile tool for converting documents in any format. It specifically has very wide support for different forms of Markdown syntax out in the wild, including a filter system that lets you extend the workings of Pandoc. Those filters can be written in any language through an API, for instance Python filters can be written using `panflute`, but there is also native support for Lua.
|
|
@@ -521,7 +521,9 @@ This is a rewrite of Entangled in Python. Older versions were written in Haskell
|
|
|
521
521
|
|
|
522
522
|
## Contributing
|
|
523
523
|
|
|
524
|
-
If you have an idea for improving Entangled, please file an issue before creating a pull request. Code in this repository is
|
|
524
|
+
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.
|
|
525
|
+
|
|
526
|
+
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.
|
|
525
527
|
|
|
526
528
|
## License
|
|
527
529
|
|
|
@@ -7,7 +7,9 @@ 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
|
|
11
|
+
from .weave import weave
|
|
10
12
|
|
|
11
13
|
__all__ = [
|
|
12
|
-
"main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei"
|
|
14
|
+
"main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei", "serve", "weave"
|
|
13
15
|
]
|
|
@@ -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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from pathlib import Path, PurePath
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import rich_click as click
|
|
5
|
+
|
|
6
|
+
from .main import main
|
|
7
|
+
|
|
8
|
+
from ..config import read_config
|
|
9
|
+
from ..interface import Context
|
|
10
|
+
from ..weave import filters, filter_description, transform_markdown
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def print_filters():
|
|
14
|
+
width = max(map(len, filters))
|
|
15
|
+
for name, f in filters.items():
|
|
16
|
+
print(f"{name:<{width}} {filter_description(f)}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@main.command()
|
|
20
|
+
@click.argument("input", type=click.Path(dir_okay=False, allow_dash=True, path_type=Path),
|
|
21
|
+
default="-")
|
|
22
|
+
@click.option("-o", "--output", type=click.Path(dir_okay=False, allow_dash=True, path_type=Path),
|
|
23
|
+
default="-", help="output file, defaults to standard output")
|
|
24
|
+
@click.option("-f", "--filter", "filter_names", type=click.Choice(list(filters)), multiple=True,
|
|
25
|
+
help="filter to apply, can be repeated; defaults to `label`")
|
|
26
|
+
@click.option("--list-filters", is_flag=True, help="list available filters and exit")
|
|
27
|
+
def weave(*, input: Path, output: Path, filter_names: tuple[str, ...] = (),
|
|
28
|
+
list_filters: bool = False):
|
|
29
|
+
"""Transform Markdown for rendering by documentation tools."""
|
|
30
|
+
if list_filters:
|
|
31
|
+
print_filters()
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
context = Context()
|
|
35
|
+
context |= read_config(context.fs)
|
|
36
|
+
|
|
37
|
+
if input == Path("-"):
|
|
38
|
+
text = sys.stdin.read()
|
|
39
|
+
filename = PurePath("-")
|
|
40
|
+
else:
|
|
41
|
+
text = input.read_text()
|
|
42
|
+
filename = input
|
|
43
|
+
|
|
44
|
+
selected = [filters[n] for n in (filter_names or ("label",))]
|
|
45
|
+
result = "".join(transform_markdown(context, text, *selected, filename=filename))
|
|
46
|
+
|
|
47
|
+
if output == Path("-"):
|
|
48
|
+
_ = sys.stdout.write(result)
|
|
49
|
+
else:
|
|
50
|
+
_ = output.write_text(result)
|
|
@@ -73,3 +73,15 @@ class CodeAttributeError(UserError):
|
|
|
73
73
|
@override
|
|
74
74
|
def __str__(self) -> str:
|
|
75
75
|
return f"{self.origin}: Attribute error: {self.msg}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class StitchConflict(UserError):
|
|
80
|
+
reference: str
|
|
81
|
+
files: tuple[Path, Path]
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def __str__(self) -> str:
|
|
85
|
+
a, b = self.files
|
|
86
|
+
return (f"code block `{self.reference}` was changed differently in "
|
|
87
|
+
f"`{a}` and `{b}`; resolve this by hand")
|
|
@@ -6,7 +6,7 @@ import msgspec
|
|
|
6
6
|
|
|
7
7
|
from entangled.config.language import Language
|
|
8
8
|
from .base import HookBase
|
|
9
|
-
from repl_session import ReplConfig, ReplSession, ReplCommand
|
|
9
|
+
from repl_session import JupyterConfig, ReplConfig, ReplSession, ReplCommand
|
|
10
10
|
from msgspec import Struct
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
@@ -18,6 +18,15 @@ from ..model.properties import Class, get_attribute, get_attribute_string, get_i
|
|
|
18
18
|
|
|
19
19
|
log = logger()
|
|
20
20
|
|
|
21
|
+
type BackendConfig = ReplConfig | JupyterConfig
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def backend_config(cfg: dict[str, object]) -> BackendConfig:
|
|
25
|
+
"""Convert a REPL config, defaulting to the `repl` (pexpect) backend when
|
|
26
|
+
no `backend` is given."""
|
|
27
|
+
return msgspec.convert({"backend": "repl"} | cfg, type=BackendConfig)
|
|
28
|
+
|
|
29
|
+
|
|
21
30
|
class Session(Struct):
|
|
22
31
|
filename: Path
|
|
23
32
|
language: str
|
|
@@ -35,7 +44,7 @@ def strip_comments(code: str, language: Language) -> str:
|
|
|
35
44
|
@final
|
|
36
45
|
class Hook(HookBase):
|
|
37
46
|
class Config(HookBase.Config):
|
|
38
|
-
config: dict[str,
|
|
47
|
+
config: dict[str, dict[str, object]] = msgspec.field(default_factory=dict)
|
|
39
48
|
|
|
40
49
|
@dataclass
|
|
41
50
|
class State(HookBase.State):
|
|
@@ -44,7 +53,8 @@ class Hook(HookBase):
|
|
|
44
53
|
def __init__(self, config: Config, state: State):
|
|
45
54
|
super().__init__(config, state)
|
|
46
55
|
log.debug(f"REPL hook config: {config}")
|
|
47
|
-
self.config =
|
|
56
|
+
self.config: dict[str, BackendConfig] = {
|
|
57
|
+
lang: backend_config(cfg) for lang, cfg in config.config.items()}
|
|
48
58
|
self.sessions: dict[str, Session] = state.sessions
|
|
49
59
|
|
|
50
60
|
@override
|
|
@@ -2,11 +2,12 @@ from dataclasses import dataclass, field
|
|
|
2
2
|
from pathlib import PurePath, Path
|
|
3
3
|
|
|
4
4
|
from ..config import Config, ConfigUpdate, get_input_files, read_config, AnnotationMethod
|
|
5
|
-
from ..model import ReferenceMap, tangle_ref, Content, content_to_text
|
|
5
|
+
from ..model import ReferenceMap, ReferenceId, tangle_ref, Content, content_to_text
|
|
6
6
|
from ..io import AbstractFileCache, FileCache, Transaction
|
|
7
7
|
from ..readers import code
|
|
8
8
|
from ..iterators import numbered_lines, run_generator
|
|
9
9
|
from ..logging import logger
|
|
10
|
+
from ..errors.user import StitchConflict
|
|
10
11
|
|
|
11
12
|
from .context import Context, markdown
|
|
12
13
|
|
|
@@ -65,18 +66,37 @@ class Document:
|
|
|
65
66
|
t.update(path)
|
|
66
67
|
return update
|
|
67
68
|
|
|
68
|
-
def load_code(self, t: Transaction, path: Path
|
|
69
|
+
def load_code(self, t: Transaction, path: Path,
|
|
70
|
+
loaded: dict[ReferenceId, Path] | None = None):
|
|
71
|
+
"""Read code blocks back from a tangled file. If `loaded` is given, it
|
|
72
|
+
records which file each block was read from, so that a block that
|
|
73
|
+
was read before with different content raises a `StitchConflict`."""
|
|
69
74
|
reader = code(numbered_lines(path, t.read(path)))
|
|
70
75
|
for block in reader:
|
|
71
|
-
|
|
76
|
+
ref = block.reference_id
|
|
77
|
+
if loaded is not None:
|
|
78
|
+
if ref in loaded and self.reference_map[ref].source != block.content:
|
|
79
|
+
raise StitchConflict(str(ref), (loaded[ref], path))
|
|
80
|
+
loaded[ref] = path
|
|
81
|
+
self.reference_map[ref].source = block.content
|
|
72
82
|
t.update(path)
|
|
73
83
|
|
|
74
84
|
def load_all_code(self, t: Transaction):
|
|
85
|
+
"""Read code blocks back from all targets that changed since they were
|
|
86
|
+
tangled. Unchanged targets are skipped: they match the documentation,
|
|
87
|
+
and when they share a block with a changed target, reading them would
|
|
88
|
+
undo the change."""
|
|
75
89
|
log.debug(f"Targets: {self.reference_map.targets()}")
|
|
90
|
+
loaded: dict[ReferenceId, Path] = {}
|
|
76
91
|
for tgt in self.reference_map.targets():
|
|
92
|
+
path = Path(tgt)
|
|
93
|
+
if path not in t.fs:
|
|
94
|
+
continue
|
|
95
|
+
if path in t.db and t.fs[path].stat == t.db[path]:
|
|
96
|
+
log.debug(f"Skipping unchanged code: `{tgt}`")
|
|
97
|
+
continue
|
|
77
98
|
log.debug(f"Reading code: `{tgt}`")
|
|
78
|
-
|
|
79
|
-
self.load_code(t, Path(tgt))
|
|
99
|
+
self.load_code(t, path, loaded)
|
|
80
100
|
|
|
81
101
|
def load(self, t: Transaction):
|
|
82
102
|
files = get_input_files(t.fs, self.config)
|
|
@@ -12,10 +12,13 @@ import time
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def hexdigest(s: str) -> str:
|
|
15
|
-
"""Creates a
|
|
16
|
-
|
|
17
|
-
is encoded as UTF-8.
|
|
18
|
-
|
|
15
|
+
"""Creates a SHA-256 hash digest from a string. Before hashing, the string
|
|
16
|
+
has carriage return `\\r` characters, trailing white space on each line
|
|
17
|
+
and trailing newlines removed, and the string is encoded as UTF-8. This
|
|
18
|
+
way, changes in white space only, like the ones made by editors that
|
|
19
|
+
strip trailing white space on save, don't count as changes."""
|
|
20
|
+
lines = s.replace("\r", "").split("\n")
|
|
21
|
+
content = "\n".join(line.rstrip() for line in lines).rstrip().encode()
|
|
19
22
|
return hashlib.sha256(content).hexdigest()
|
|
20
23
|
|
|
21
24
|
|
|
@@ -20,7 +20,7 @@ class WhitespaceStrippingConsole(Console):
|
|
|
20
20
|
return "\n".join(line.rstrip() for line in rendered.splitlines()) + newline_char
|
|
21
21
|
|
|
22
22
|
# Global rich console object
|
|
23
|
-
console: Console = WhitespaceStrippingConsole()
|
|
23
|
+
console: Console = WhitespaceStrippingConsole(stderr=True)
|
|
24
24
|
|
|
25
25
|
def logger():
|
|
26
26
|
return logging.getLogger("entangled")
|
|
@@ -75,7 +75,9 @@ type Tangler = Callable[[Tangler, Deps, ReferenceId, bool, bool], Iterator[str]]
|
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
def indent(prefix: str, g: Iterator[str]) -> Iterator[str]:
|
|
78
|
-
|
|
78
|
+
"""Indent lines with `prefix`. Blank lines are not indented, so that they
|
|
79
|
+
don't get trailing white space."""
|
|
80
|
+
return map(lambda line: prefix + line if line.strip() else line.lstrip(" \t"), g)
|
|
79
81
|
|
|
80
82
|
|
|
81
83
|
def naked_tangler(refs: ReferenceMap) -> Tangler:
|
|
@@ -48,7 +48,11 @@ def code_block_guard(origin: TextLocation, open_match: re.Match[str], close_matc
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def dedent_line(location: TextLocation, indent: str, line: str):
|
|
51
|
-
|
|
51
|
+
# Blank lines lose all their white space, as they do when reading code
|
|
52
|
+
# back from a tangled file.
|
|
53
|
+
if line.strip() == "":
|
|
54
|
+
return line.lstrip(" \t")
|
|
55
|
+
if line.startswith(indent):
|
|
52
56
|
return line.removeprefix(indent)
|
|
53
57
|
raise IndentationError(location)
|
|
54
58
|
|
|
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)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .base import (
|
|
2
|
+
ContentFilter, CodeBlockFilter, codeblock_filter, compose_filters, iter_bind, transform_markdown
|
|
3
|
+
)
|
|
4
|
+
from .filters import filters, filter_description, block_title
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ContentFilter", "CodeBlockFilter", "codeblock_filter", "compose_filters", "iter_bind",
|
|
8
|
+
"transform_markdown", "filters", "filter_description", "block_title"
|
|
9
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic machinery for weaving: transforming the Markdown source of a page,
|
|
3
|
+
code block by code block, into Markdown that renders well in some target.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
7
|
+
from functools import partial, reduce, wraps
|
|
8
|
+
from itertools import chain
|
|
9
|
+
from pathlib import PurePath
|
|
10
|
+
|
|
11
|
+
from ..interface import Context, markdown
|
|
12
|
+
from ..iterators import numbered_lines
|
|
13
|
+
from ..model import Content, PlainText, ReferenceId, ReferenceMap, content_to_text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
type ContentFilter = Callable[[ReferenceMap, Content], Iterable[Content]]
|
|
17
|
+
type CodeBlockFilter = Callable[[ReferenceMap, ReferenceId], Iterable[Content]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def codeblock_filter(f: CodeBlockFilter) -> ContentFilter:
|
|
21
|
+
"""Lift a filter on code blocks to a filter on content, passing plain text
|
|
22
|
+
through unchanged."""
|
|
23
|
+
@wraps(f)
|
|
24
|
+
def _filter(rm: ReferenceMap, c: Content) -> Iterable[Content]:
|
|
25
|
+
match c:
|
|
26
|
+
case PlainText():
|
|
27
|
+
return [c]
|
|
28
|
+
case ReferenceId():
|
|
29
|
+
return f(rm, c)
|
|
30
|
+
return _filter
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def iter_bind[T, U](lst: Iterable[T], f: Callable[[T], Iterable[U]]) -> Iterator[U]:
|
|
34
|
+
return chain.from_iterable(map(f, lst))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def compose_filter(a: ContentFilter, b: ContentFilter) -> ContentFilter:
|
|
38
|
+
def _joined(rm: ReferenceMap, c: Content) -> Iterable[Content]:
|
|
39
|
+
return iter_bind(a(rm, c), partial(b, rm))
|
|
40
|
+
return _joined
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compose_filters(*args: ContentFilter) -> ContentFilter:
|
|
44
|
+
"""Compose filters left to right: the first filter is applied first."""
|
|
45
|
+
identity: ContentFilter = lambda _, c: [c]
|
|
46
|
+
return reduce(compose_filter, args, identity)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def transform_markdown(
|
|
50
|
+
context: Context,
|
|
51
|
+
text: str,
|
|
52
|
+
*filters: ContentFilter,
|
|
53
|
+
filename: PurePath = PurePath("-"),
|
|
54
|
+
reference_map: ReferenceMap | None = None,
|
|
55
|
+
) -> Iterator[str]:
|
|
56
|
+
"""
|
|
57
|
+
Read a Markdown document, pass its content through the given filters,
|
|
58
|
+
and yield the resulting Markdown text.
|
|
59
|
+
|
|
60
|
+
Filters may modify code blocks in the reference map in place. If no
|
|
61
|
+
`reference_map` is given, a fresh one is used for this document only.
|
|
62
|
+
"""
|
|
63
|
+
refs = ReferenceMap() if reference_map is None else reference_map
|
|
64
|
+
content = markdown(context, refs, numbered_lines(filename, text))
|
|
65
|
+
for c in iter_bind(content, partial(compose_filters(*filters), refs)):
|
|
66
|
+
yield content_to_text(refs, c)[0]
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filters for `entangled weave`. Each filter is registered by name in `filters`;
|
|
3
|
+
the first line of its docstring serves as description for `--list-filters`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from base64 import b64encode
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
from html import escape
|
|
9
|
+
from io import StringIO
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from textwrap import indent
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
|
|
16
|
+
from repl_session import ReplCommand, ReplOutput, ReplSession, read_session
|
|
17
|
+
|
|
18
|
+
from ..hooks.repl import strip_comments
|
|
19
|
+
from ..logging import logger
|
|
20
|
+
from ..model import CodeBlock, Content, PlainText, ReferenceId, ReferenceMap
|
|
21
|
+
from ..model.properties import Attribute, Class, Id, get_attribute_string
|
|
22
|
+
from .base import ContentFilter, codeblock_filter
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
log = logger()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def block_title(code_block: CodeBlock) -> str | None:
|
|
29
|
+
"""Compute a human readable title for a code block from its id and `file`
|
|
30
|
+
attribute. Returns `None` for anonymous code blocks."""
|
|
31
|
+
ids = [p.value for p in code_block.properties if isinstance(p, Id)]
|
|
32
|
+
filenames = [p.value for p in code_block.properties # pyright: ignore[reportAny]
|
|
33
|
+
if isinstance(p, Attribute) and p.key == "file"]
|
|
34
|
+
|
|
35
|
+
if len(ids) > 1 or len(filenames) > 1:
|
|
36
|
+
return "error: ambiguous code block title"
|
|
37
|
+
if ids and filenames:
|
|
38
|
+
return f"#{ids[0]} / file: {filenames[0]}"
|
|
39
|
+
if ids:
|
|
40
|
+
return f"#{ids[0]}"
|
|
41
|
+
if filenames:
|
|
42
|
+
return f"file: {filenames[0]}"
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def first_line(text: str) -> tuple[str, str]:
|
|
47
|
+
head, nl, rest = text.partition("\n")
|
|
48
|
+
return head, nl + rest
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@codeblock_filter
|
|
52
|
+
def title(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
|
|
53
|
+
"""Add a `title` attribute to the code block header (MkDocs/pymdownx style)."""
|
|
54
|
+
code_block = reference_map[r]
|
|
55
|
+
text = block_title(code_block)
|
|
56
|
+
ids = [p.value for p in code_block.properties if isinstance(p, Id)]
|
|
57
|
+
classes = [p.value for p in code_block.properties if isinstance(p, Class)]
|
|
58
|
+
|
|
59
|
+
head, rest = first_line(code_block.open_line)
|
|
60
|
+
fence_match = re.match(r"\s*(`{3,}|~{3,})", head)
|
|
61
|
+
fence = fence_match.group(1) if fence_match else "```"
|
|
62
|
+
|
|
63
|
+
attrs: list[str] = []
|
|
64
|
+
if ids:
|
|
65
|
+
attrs.append(str(Id(ids[0])))
|
|
66
|
+
attrs.extend(str(Class(c)) for c in classes[1:])
|
|
67
|
+
if text:
|
|
68
|
+
attrs.append(str(Attribute("title", text)))
|
|
69
|
+
|
|
70
|
+
head = fence + (classes[0] if classes else "")
|
|
71
|
+
if attrs:
|
|
72
|
+
head += " {" + " ".join(attrs) + "}"
|
|
73
|
+
|
|
74
|
+
code_block.open_line = head + (rest or "\n")
|
|
75
|
+
return [r]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@codeblock_filter
|
|
79
|
+
def label(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
|
|
80
|
+
"""Wrap code blocks in a `<div>` with a label; renders in most Markdown previews."""
|
|
81
|
+
code_block = reference_map[r]
|
|
82
|
+
text = block_title(code_block)
|
|
83
|
+
if text is None:
|
|
84
|
+
return [r]
|
|
85
|
+
|
|
86
|
+
ind = code_block.indent
|
|
87
|
+
header = (f"{ind}<div class=\"entangled-code-block\" markdown=\"1\">\n"
|
|
88
|
+
f"{ind}<div class=\"entangled-code-title\"><code>{escape(text)}</code></div>\n\n")
|
|
89
|
+
# The closing fence may lack a newline at the end of a file. The blank
|
|
90
|
+
# lines around `</div>` end the HTML block in CommonMark.
|
|
91
|
+
eol = "" if code_block.close_line.endswith("\n") else "\n"
|
|
92
|
+
footer = f"{eol}\n{ind}</div>\n\n"
|
|
93
|
+
return [PlainText(header), r, PlainText(footer)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# Representations to pick from a MIME bundle, most preferred first.
|
|
97
|
+
MIME_PRIORITY = [
|
|
98
|
+
"text/markdown", "text/html", "image/svg+xml", "image/png", "image/jpeg", "image/gif",
|
|
99
|
+
"text/plain",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def fenced(text: str, classes: list[str]) -> str:
|
|
104
|
+
"""Put text in a fenced code block, with a fence longer than any backtick
|
|
105
|
+
run inside the text."""
|
|
106
|
+
longest = max((len(m) for m in re.findall(r"`+", text)), default=0)
|
|
107
|
+
fence = "`" * max(3, longest + 1)
|
|
108
|
+
attrs = " ".join(str(Class(c)) for c in classes)
|
|
109
|
+
return f"{fence} {{{attrs}}}\n{text}\n{fence}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def render_mime(mime: str, data: str, kind: str) -> str | None:
|
|
113
|
+
"""Render a single representation as Markdown, or `None` if there is
|
|
114
|
+
nothing to show."""
|
|
115
|
+
match mime:
|
|
116
|
+
case "text/plain":
|
|
117
|
+
text = data.strip("\n")
|
|
118
|
+
if not text.strip():
|
|
119
|
+
return None
|
|
120
|
+
return fenced(text, ["text", "output"] + ([] if kind == "result" else [kind]))
|
|
121
|
+
case "text/markdown" | "text/html":
|
|
122
|
+
return data.strip("\n") or None
|
|
123
|
+
case "image/svg+xml":
|
|
124
|
+
return f").decode()})"
|
|
125
|
+
case _ if mime.startswith("image/"):
|
|
126
|
+
return f")})"
|
|
127
|
+
case _:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def render_output(output: ReplOutput, output_type: str) -> str | None:
|
|
132
|
+
"""Render the most preferred representation in an output's MIME bundle.
|
|
133
|
+
A `mime-type` set on the code block, other than the default `text/plain`,
|
|
134
|
+
takes precedence."""
|
|
135
|
+
preferred = [] if output_type == "text/plain" else [output_type]
|
|
136
|
+
for mime in [*preferred, *MIME_PRIORITY]:
|
|
137
|
+
if mime in output.data:
|
|
138
|
+
return render_mime(mime, output.data[mime], output.kind)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def command_outputs(command: ReplCommand) -> list[ReplOutput]:
|
|
143
|
+
"""Outputs of a command; sessions from older versions of `repl-session`
|
|
144
|
+
only have the `output` field."""
|
|
145
|
+
if command.outputs is not None:
|
|
146
|
+
return command.outputs
|
|
147
|
+
if command.output is None:
|
|
148
|
+
return []
|
|
149
|
+
return [ReplOutput("result", {command.output_type: command.output})]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@lru_cache(maxsize=16)
|
|
153
|
+
def decode_session(text: str) -> ReplSession:
|
|
154
|
+
"""Decode the output of a REPL session. Every code block in the session
|
|
155
|
+
needs it, so we cache on the contents of the file; modification times
|
|
156
|
+
are too coarse to tell when a file was rewritten."""
|
|
157
|
+
return read_session(StringIO(text))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def session_output(path: Path) -> ReplSession | None:
|
|
161
|
+
try:
|
|
162
|
+
return decode_session(path.read_text())
|
|
163
|
+
except FileNotFoundError:
|
|
164
|
+
log.warning("REPL session output `%s` not found; run `repl-session` first.", path)
|
|
165
|
+
except msgspec.MsgspecError as e:
|
|
166
|
+
log.warning("REPL session output `%s` could not be read: %s", path, e)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def find_command(reference_map: ReferenceMap, r: ReferenceId) -> ReplCommand | None:
|
|
171
|
+
"""Find the command for a code block in the output of its REPL session.
|
|
172
|
+
The session is opened by the first code block of the same name that has
|
|
173
|
+
the `repl` class and a `session` attribute; every code block of that name
|
|
174
|
+
from there on is a command in the session."""
|
|
175
|
+
refs = reference_map.select_by_name(r.name)
|
|
176
|
+
try:
|
|
177
|
+
start = next(i for i, ref in enumerate(refs)
|
|
178
|
+
if Class("repl") in reference_map[ref].properties
|
|
179
|
+
and get_attribute_string(reference_map[ref].properties, "session"))
|
|
180
|
+
except StopIteration:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
session_file = get_attribute_string(reference_map[refs[start]].properties, "session")
|
|
184
|
+
assert session_file is not None
|
|
185
|
+
session = session_output(Path(session_file).with_suffix(".out.json"))
|
|
186
|
+
if session is None:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
index = refs.index(r) - start
|
|
190
|
+
code_block = reference_map[r]
|
|
191
|
+
if not 0 <= index < len(session.commands):
|
|
192
|
+
log.warning("%s: no output for this code block in REPL session `%s`.",
|
|
193
|
+
code_block.origin, session_file)
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
command = session.commands[index]
|
|
197
|
+
if code_block.language is not None \
|
|
198
|
+
and command.command.strip() != strip_comments(code_block.source, code_block.language).strip():
|
|
199
|
+
log.warning("%s: REPL session `%s` is out of date; tangle and run `repl-session` again.",
|
|
200
|
+
code_block.origin, session_file)
|
|
201
|
+
return None
|
|
202
|
+
return command
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@codeblock_filter
|
|
206
|
+
def repl(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
|
|
207
|
+
"""Insert the output of REPL sessions (from the `repl` hook) after code blocks with the `repl` class."""
|
|
208
|
+
code_block = reference_map[r]
|
|
209
|
+
if Class("repl") not in code_block.properties:
|
|
210
|
+
return [r]
|
|
211
|
+
|
|
212
|
+
command = find_command(reference_map, r)
|
|
213
|
+
if command is None:
|
|
214
|
+
return [r]
|
|
215
|
+
|
|
216
|
+
rendered = [text for out in command_outputs(command)
|
|
217
|
+
if (text := render_output(out, command.output_type)) is not None]
|
|
218
|
+
if not rendered:
|
|
219
|
+
return [r]
|
|
220
|
+
|
|
221
|
+
# The closing fence may lack a newline at the end of a file.
|
|
222
|
+
eol = "" if code_block.close_line.endswith("\n") else "\n"
|
|
223
|
+
body = "".join(indent(f"\n{text}\n", code_block.indent) for text in rendered)
|
|
224
|
+
return [r, PlainText(eol + body)]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
filters: dict[str, ContentFilter] = {
|
|
228
|
+
"label": label,
|
|
229
|
+
"repl": repl,
|
|
230
|
+
"title": title,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def filter_description(f: ContentFilter) -> str:
|
|
235
|
+
return (f.__doc__ or "").strip().partition("\n")[0]
|
|
@@ -1,25 +1,30 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "entangled-cli"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.6.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",
|
|
16
16
|
"pyyaml>=6.0.1,<7",
|
|
17
|
-
"repl-session>=0.
|
|
17
|
+
"repl-session>=0.3.0",
|
|
18
18
|
"msgspec>=0.20.0",
|
|
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
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
jupyter = [
|
|
27
|
+
"repl-session[jupyter]>=0.3.0",
|
|
23
28
|
]
|
|
24
29
|
|
|
25
30
|
[project.urls]
|
|
@@ -32,8 +37,7 @@ entangled = "entangled.main:cli"
|
|
|
32
37
|
[dependency-groups]
|
|
33
38
|
dev = [
|
|
34
39
|
"pytest>=9.0,<10",
|
|
35
|
-
"mypy>=
|
|
36
|
-
"black>=23.3.0,<24",
|
|
40
|
+
"mypy>=2.4.0",
|
|
37
41
|
"pytest-cov>=4.0.0,<5",
|
|
38
42
|
"mkdocs>=1.4.3,<2",
|
|
39
43
|
"mkdocs-material>=9.7,<10",
|
|
@@ -48,7 +52,7 @@ dev = [
|
|
|
48
52
|
"types-pexpect>=4.9.0.20250916",
|
|
49
53
|
"types-PyYAML>=6.0.1,<7",
|
|
50
54
|
"pytest-timeout>=2.4.0",
|
|
51
|
-
"
|
|
55
|
+
"ipykernel>=7.0",
|
|
52
56
|
]
|
|
53
57
|
|
|
54
58
|
[tool.hatch.build.targets.sdist]
|
|
@@ -70,5 +74,10 @@ testpaths = ["test"]
|
|
|
70
74
|
[tool.mypy]
|
|
71
75
|
packages = ["entangled"]
|
|
72
76
|
|
|
77
|
+
[tool.uv.workspace]
|
|
78
|
+
members = [
|
|
79
|
+
".",
|
|
80
|
+
]
|
|
81
|
+
|
|
73
82
|
[tool.uv.sources]
|
|
74
|
-
|
|
83
|
+
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
|
|
File without changes
|
|
File without changes
|