entangled-cli 2.4.2__tar.gz → 2.5.0__tar.gz

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