entangled-cli 2.4.2__tar.gz → 2.4.3__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 (71) hide show
  1. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/PKG-INFO +7 -1
  2. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/README.md +6 -0
  3. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/watch.py +5 -1
  4. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/quarto_attributes.py +13 -2
  5. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/interface/document.py +5 -4
  6. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/io/filedb.py +4 -1
  7. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/io/transaction.py +7 -4
  8. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/iterators/lines.py +2 -5
  9. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/tangle.py +14 -10
  10. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/parsing.py +13 -2
  11. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/readers/code.py +13 -11
  12. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/pyproject.toml +1 -1
  13. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/.gitignore +0 -0
  14. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/LICENSE +0 -0
  15. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/__init__.py +0 -0
  16. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/__init__.py +0 -0
  17. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/brei.py +0 -0
  18. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/main.py +0 -0
  19. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/new.py +0 -0
  20. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/reset.py +0 -0
  21. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/status.py +0 -0
  22. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/stitch.py +0 -0
  23. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/sync.py +0 -0
  24. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/commands/tangle.py +0 -0
  25. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/__init__.py +0 -0
  26. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/annotation_method.py +0 -0
  27. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/config_data.py +0 -0
  28. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/config_update.py +0 -0
  29. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/document_style.py +0 -0
  30. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/language.py +0 -0
  31. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/markers.py +0 -0
  32. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/namespace_default.py +0 -0
  33. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/templates.py +0 -0
  34. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/config/version.py +0 -0
  35. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/errors/__init__.py +0 -0
  36. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/errors/internal.py +0 -0
  37. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/errors/user.py +0 -0
  38. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/__init__.py +0 -0
  39. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/base.py +0 -0
  40. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/build.py +0 -0
  41. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/repl.py +0 -0
  42. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/shebang.py +0 -0
  43. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/spdx_license.py +0 -0
  44. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/hooks/task.py +0 -0
  45. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/interface/__init__.py +0 -0
  46. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/interface/context.py +0 -0
  47. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/io/__init__.py +0 -0
  48. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/io/stat.py +0 -0
  49. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/io/virtual.py +0 -0
  50. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/iterators/__init__.py +0 -0
  51. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/iterators/peekable.py +0 -0
  52. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/iterators/run_generator.py +0 -0
  53. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/logging.py +0 -0
  54. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/main.py +0 -0
  55. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/__init__.py +0 -0
  56. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/code_block.py +0 -0
  57. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/content.py +0 -0
  58. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/properties.py +0 -0
  59. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/reference_id.py +0 -0
  60. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/reference_map.py +0 -0
  61. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/model/reference_name.py +0 -0
  62. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/py.typed +0 -0
  63. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/readers/__init__.py +0 -0
  64. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/readers/delimiters.py +0 -0
  65. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/readers/markdown.py +0 -0
  66. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/readers/types.py +0 -0
  67. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/readers/yaml_header.py +0 -0
  68. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/status.py +0 -0
  69. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/text_location.py +0 -0
  70. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/utility.py +0 -0
  71. {entangled_cli-2.4.2 → entangled_cli-2.4.3}/entangled/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entangled-cli
3
- Version: 2.4.2
3
+ Version: 2.4.3
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
@@ -58,6 +58,12 @@ Or, if you prefer plain old `pip`,
58
58
  pip install entangled-cli
59
59
  ```
60
60
 
61
+ Or, if you prefer the nix package manager:
62
+
63
+ ```
64
+ nix run github:entangled/entangled.py
65
+ ```
66
+
61
67
  ## Use
62
68
 
63
69
  Run the `entangled watch` daemon in the root of your project folder. By default all Markdown files are monitored for fenced code blocks like so:
@@ -33,6 +33,12 @@ Or, if you prefer plain old `pip`,
33
33
  pip install entangled-cli
34
34
  ```
35
35
 
36
+ Or, if you prefer the nix package manager:
37
+
38
+ ```
39
+ nix run github:entangled/entangled.py
40
+ ```
41
+
36
42
  ## Use
37
43
 
38
44
  Run the `entangled watch` daemon in the root of your project folder. By default all Markdown files are monitored for fenced code blocks like so:
@@ -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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "entangled-cli"
3
- version = "2.4.2"
3
+ version = "2.4.3"
4
4
  description = "Literate Programming toolbox"
5
5
  authors = [{ name = "Johan Hidding", email = "j.hidding@esciencecenter.nl" }]
6
6
  requires-python = ">=3.13,<4"
File without changes
File without changes