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.
Files changed (78) hide show
  1. entangled_cli-2.4.3/README.md → entangled_cli-2.6.0/PKG-INFO +62 -8
  2. entangled_cli-2.4.3/PKG-INFO → entangled_cli-2.6.0/README.md +35 -33
  3. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/__init__.py +3 -1
  4. entangled_cli-2.6.0/entangled/commands/serve.py +57 -0
  5. entangled_cli-2.6.0/entangled/commands/weave.py +50 -0
  6. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/errors/user.py +12 -0
  7. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/repl.py +13 -3
  8. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/interface/document.py +25 -5
  9. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/stat.py +7 -4
  10. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/logging.py +1 -1
  11. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/tangle.py +3 -1
  12. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/markdown.py +5 -1
  13. entangled_cli-2.6.0/entangled/server/__init__.py +0 -0
  14. entangled_cli-2.6.0/entangled/server/server.py +86 -0
  15. entangled_cli-2.6.0/entangled/weave/__init__.py +9 -0
  16. entangled_cli-2.6.0/entangled/weave/base.py +66 -0
  17. entangled_cli-2.6.0/entangled/weave/filters.py +235 -0
  18. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/pyproject.toml +19 -10
  19. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/.gitignore +0 -0
  20. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/LICENSE +0 -0
  21. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/__init__.py +0 -0
  22. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/brei.py +0 -0
  23. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/main.py +0 -0
  24. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/new.py +0 -0
  25. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/reset.py +0 -0
  26. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/status.py +0 -0
  27. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/stitch.py +0 -0
  28. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/sync.py +0 -0
  29. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/tangle.py +0 -0
  30. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/commands/watch.py +0 -0
  31. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/__init__.py +0 -0
  32. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/annotation_method.py +0 -0
  33. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/config_data.py +0 -0
  34. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/config_update.py +0 -0
  35. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/document_style.py +0 -0
  36. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/language.py +0 -0
  37. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/markers.py +0 -0
  38. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/namespace_default.py +0 -0
  39. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/templates.py +0 -0
  40. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/config/version.py +0 -0
  41. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/errors/__init__.py +0 -0
  42. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/errors/internal.py +0 -0
  43. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/__init__.py +0 -0
  44. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/base.py +0 -0
  45. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/build.py +0 -0
  46. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/quarto_attributes.py +0 -0
  47. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/shebang.py +0 -0
  48. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/spdx_license.py +0 -0
  49. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/hooks/task.py +0 -0
  50. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/interface/__init__.py +0 -0
  51. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/interface/context.py +0 -0
  52. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/__init__.py +0 -0
  53. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/filedb.py +0 -0
  54. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/transaction.py +0 -0
  55. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/io/virtual.py +0 -0
  56. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/__init__.py +0 -0
  57. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/lines.py +0 -0
  58. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/peekable.py +0 -0
  59. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/iterators/run_generator.py +0 -0
  60. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/main.py +0 -0
  61. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/__init__.py +0 -0
  62. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/code_block.py +0 -0
  63. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/content.py +0 -0
  64. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/properties.py +0 -0
  65. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/reference_id.py +0 -0
  66. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/reference_map.py +0 -0
  67. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/model/reference_name.py +0 -0
  68. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/parsing.py +0 -0
  69. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/py.typed +0 -0
  70. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/__init__.py +0 -0
  71. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/code.py +0 -0
  72. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/delimiters.py +0 -0
  73. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/types.py +0 -0
  74. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/readers/yaml_header.py +0 -0
  75. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/status.py +0 -0
  76. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/text_location.py +0 -0
  77. {entangled_cli-2.4.3 → entangled_cli-2.6.0}/entangled/utility.py +0 -0
  78. {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
  [![Upload Python Package](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml/badge.svg)](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 [![Entangled badge](https://img.shields.io/badge/entangled-Use%20the%20source!-%2300aeff)](https://entangled.github.io/) to the appropriate location in your project:
14
41
 
15
42
  ```markdown
16
- [![Entangled badge](https://img.shields.io/badge/entangled-Use%20the%20source!-%2300aeff)](https://entangled.github.io/)
43
+ [![Entangled badge](https://img.shields.io/badge/entangled-Use%19the%20source!-%2300aeff)](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 &ge;3.11) installation. If you use [`poetry`](https://python-poetry.org), and you start a new project,
48
+ To install Entangled, all you need is a Python (version &ge;3.11) installation. If you use UV, and you start a new project,
22
49
 
23
50
  ```
24
- poetry init
25
- poetry add entangled-cli
51
+ uv init --bare
52
+ uv add entangled-cli
26
53
  ```
27
54
 
28
- The `poetry init` command will create a `pyproject.toml` file and a virtual environment to install Python dependencies in. To activate the virtual environment, run `poetry shell` inside the project directory.
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
- pip install entangled-cli
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 formatted using `black` and type checked using `mypy`.
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
  [![Upload Python Package](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml/badge.svg)](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 [![Entangled badge](https://img.shields.io/badge/entangled-Use%20the%20source!-%2300aeff)](https://entangled.github.io/) to the appropriate location in your project:
39
14
 
40
15
  ```markdown
41
- [![Entangled badge](https://img.shields.io/badge/entangled-Use%20the%20source!-%2300aeff)](https://entangled.github.io/)
16
+ [![Entangled badge](https://img.shields.io/badge/entangled-Use%19the%20source!-%2300aeff)](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 &ge;3.11) installation. If you use [`poetry`](https://python-poetry.org), and you start a new project,
21
+ To install Entangled, all you need is a Python (version &ge;3.11) installation. If you use UV, and you start a new project,
47
22
 
48
23
  ```
49
- poetry init
50
- poetry add entangled-cli
24
+ uv init --bare
25
+ uv add entangled-cli
51
26
  ```
52
27
 
53
- The `poetry init` command will create a `pyproject.toml` file and a virtual environment to install Python dependencies in. To activate the virtual environment, run `poetry shell` inside the project directory.
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
- pip install entangled-cli
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 formatted using `black` and type checked using `mypy`.
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, ReplConfig] = msgspec.field(default_factory=dict)
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 = config.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
- self.reference_map[block.reference_id].source = block.content
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
- if Path(tgt) in t.fs:
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 MD5 hash digest from a string. Before hashing, the string has
16
- linefeed `\\r` characters and trailing newlines removed, and the string
17
- is encoded as UTF-8."""
18
- content = s.replace("\r", "").rstrip().encode()
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
- return map(lambda line: "" if not line else prefix + line, g)
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
- if line.startswith(indent) or line.strip() == "":
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"![](data:{mime};base64,{b64encode(data.encode()).decode()})"
125
+ case _ if mime.startswith("image/"):
126
+ return f"![](data:{mime};base64,{"".join(data.split())})"
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.4.3"
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,<4",
11
- "rich>=13,<15",
12
- "tomlkit>=0.12.1,<0.13",
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.2.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.1.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>=1.18,<2",
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
- "ty>=0.0.1a24",
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
- watchdog = { git = "https://github.com/gorakhargosh/watchdog" }
83
+ entangled-cli = { workspace = true, editable = true }
File without changes
File without changes