entangled-cli 2.2.1__tar.gz → 2.3.1__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 (52) hide show
  1. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/PKG-INFO +1 -1
  2. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/code_reader.py +1 -1
  3. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/__init__.py +2 -0
  4. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/brei.py +6 -6
  5. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/new.py +7 -8
  6. entangled_cli-2.3.1/entangled/commands/reset.py +62 -0
  7. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/status.py +6 -3
  8. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/stitch.py +9 -10
  9. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/sync.py +8 -7
  10. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/tangle.py +3 -13
  11. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/config/__init__.py +15 -5
  12. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/document.py +4 -2
  13. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/hooks/base.py +5 -5
  14. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/hooks/build.py +1 -1
  15. entangled_cli-2.3.1/entangled/hooks/quarto_attributes.py +86 -0
  16. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/hooks/repl.py +1 -1
  17. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/hooks/task.py +1 -1
  18. entangled_cli-2.3.1/entangled/io/__init__.py +11 -0
  19. entangled_cli-2.3.1/entangled/io/filedb.py +124 -0
  20. entangled_cli-2.3.1/entangled/io/stat.py +70 -0
  21. entangled_cli-2.3.1/entangled/io/transaction.py +258 -0
  22. entangled_cli-2.3.1/entangled/io/virtual.py +99 -0
  23. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/logging.py +4 -4
  24. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/main.py +2 -2
  25. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/markdown_reader.py +5 -4
  26. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/parsing.py +50 -59
  27. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/properties.py +22 -30
  28. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/status.py +5 -5
  29. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/tangle.py +7 -5
  30. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/pyproject.toml +2 -2
  31. entangled_cli-2.2.1/entangled/filedb.py +0 -207
  32. entangled_cli-2.2.1/entangled/hooks/quarto_attributes.py +0 -61
  33. entangled_cli-2.2.1/entangled/transaction.py +0 -278
  34. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/.gitignore +0 -0
  35. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/LICENSE +0 -0
  36. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/README.md +0 -0
  37. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/__init__.py +0 -0
  38. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/commands/watch.py +0 -0
  39. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/config/language.py +0 -0
  40. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/config/templates.py +0 -0
  41. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/config/version.py +0 -0
  42. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/errors/__init__.py +0 -0
  43. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/errors/internal.py +0 -0
  44. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/errors/user.py +0 -0
  45. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/from_str.py +0 -0
  46. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/hooks/__init__.py +0 -0
  47. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/hooks/shebang.py +0 -0
  48. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/hooks/spdx_license.py +0 -0
  49. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/py.typed +0 -0
  50. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/text_location.py +0 -0
  51. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/utility.py +0 -0
  52. {entangled_cli-2.2.1 → entangled_cli-2.3.1}/entangled/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entangled-cli
3
- Version: 2.2.1
3
+ Version: 2.3.1
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
@@ -1,5 +1,5 @@
1
1
  from dataclasses import dataclass, field
2
- from pathlib import Path, PurePath
2
+ from pathlib import PurePath
3
3
 
4
4
  import mawk
5
5
  import re
@@ -3,12 +3,14 @@ from .status import status
3
3
  from .stitch import stitch
4
4
  from .sync import sync
5
5
  from .tangle import tangle
6
+ from .reset import reset
6
7
  from .watch import watch
7
8
  from .brei import brei
8
9
 
9
10
  __all__ = [
10
11
  "new",
11
12
  "brei",
13
+ "reset",
12
14
  "status",
13
15
  "stitch",
14
16
  "sync",
@@ -1,5 +1,6 @@
1
1
  from pathlib import Path
2
- from typing import Awaitable, Optional
2
+ from collections.abc import Awaitable
3
+ from typing import Any
3
4
  import argh # type: ignore
4
5
  import asyncio
5
6
  import textwrap
@@ -11,15 +12,15 @@ from ..logging import logger
11
12
  log = logger()
12
13
 
13
14
 
14
- async def main(target_strs: list[str], force_run: bool, throttle: Optional[int]):
15
+ async def main(target_strs: list[str], force_run: bool, throttle: int | None):
15
16
  if not Path(".entangled").exists():
16
17
  Path(".entangled").mkdir()
17
18
 
18
- db = await resolve_tasks(config.brei, Path(".entangled/brei_history"))
19
+ db = await resolve_tasks(config.get.brei, Path(".entangled/brei_history"))
19
20
  if throttle:
20
21
  db.throttle = asyncio.Semaphore(throttle)
21
22
  db.force_run = force_run
22
- jobs: list[Awaitable] = [db.run(Phony(t), db=db) for t in target_strs]
23
+ jobs: list[Awaitable[Any]] = [db.run(Phony(t), db=db) for t in target_strs]
23
24
  with db.persistent_history():
24
25
  results = await asyncio.gather(*jobs)
25
26
 
@@ -35,8 +36,7 @@ async def main(target_strs: list[str], force_run: bool, throttle: Optional[int])
35
36
  @argh.arg("targets", nargs="+", help="name of target to run")
36
37
  @argh.arg("-B", "--force-run", help="rebuild all dependencies")
37
38
  @argh.arg("-j", "--throttle", help="limit number of concurrent jobs")
38
- def brei(targets: list[str], *, force_run: bool = False, throttle: Optional[int] = None):
39
+ def brei(targets: list[str], *, force_run: bool = False, throttle: int | None = None):
39
40
  """Build one of the configured targets."""
40
41
  config.read()
41
42
  asyncio.run(main(targets, force_run, throttle))
42
-
@@ -1,10 +1,9 @@
1
- from typing import Optional
2
-
3
1
  import argh # type: ignore
2
+ from argh.utils import get_subparsers
4
3
  import argparse
5
4
 
6
5
  from pathlib import Path
7
- from brei.cli import RichHelpFormatter
6
+ from rich_argparse import RichHelpFormatter
8
7
  from rich.console import Console
9
8
  from rich.table import Table
10
9
 
@@ -45,7 +44,7 @@ def print_help() -> None:
45
44
  """
46
45
  parser = argparse.ArgumentParser(formatter_class=RichHelpFormatter)
47
46
  argh.add_commands(parser, [new], func_kwargs={"formatter_class": RichHelpFormatter})
48
- argh.utils.get_subparsers(parser).choices["new"].print_help()
47
+ get_subparsers(parser).choices["new"].print_help()
49
48
 
50
49
 
51
50
  @argh.arg(
@@ -96,9 +95,9 @@ def print_help() -> None:
96
95
  help="Initialize a new project at this path",
97
96
  )
98
97
  def new(
99
- template: Optional[str],
100
- project_path: Optional[Path], *,
101
- answers_file: Optional[str] = None,
98
+ template: str | None,
99
+ project_path: Path | None, *,
100
+ answers_file: str | None = None,
102
101
  data: str = "",
103
102
  defaults: bool = False,
104
103
  pretend: bool = False,
@@ -135,7 +134,7 @@ def new(
135
134
  copy_this_template = template_option.url
136
135
  break
137
136
 
138
- data_dict: dict = {}
137
+ data_dict: dict[str, str] = {}
139
138
  if data:
140
139
  try:
141
140
  for d in data.split(";"):
@@ -0,0 +1,62 @@
1
+ """
2
+ The `reset` command resets the file database in `.entangled/filedb.json`.
3
+ This database gets updated every time you tangle or stitch, but sometimes
4
+ its contents may become invalid, for instance when switching branches.
5
+ This command will read the markdown sources, then pretend to be tangling
6
+ without actually writing out to source files.
7
+ """
8
+
9
+ from ..io import TransactionMode, transaction
10
+ from ..config import config, get_input_files
11
+ from ..hooks import get_hooks
12
+ from ..document import ReferenceMap
13
+ from ..errors.user import UserError
14
+
15
+ import logging
16
+ from pathlib import Path
17
+
18
+
19
+ def reset():
20
+ """
21
+ Resets the database. This performs a tangle without actually writing
22
+ output to the files, but updating the database as if we were.
23
+ """
24
+ config.read()
25
+
26
+ # these imports depend on config being read
27
+ from ..markdown_reader import read_markdown_file
28
+ from ..tangle import tangle_ref
29
+
30
+ input_file_list = get_input_files()
31
+
32
+ refs = ReferenceMap()
33
+ hooks = get_hooks()
34
+ logging.debug("tangling with hooks: %s", [h.__module__ for h in hooks])
35
+ mode = TransactionMode.RESETDB
36
+ annotation_method = config.get.annotation
37
+
38
+ try:
39
+ with transaction(mode) as t:
40
+ for path in input_file_list:
41
+ logging.debug("reading `%s`", path)
42
+ t.update(path)
43
+ _, _ = read_markdown_file(t, path, refs=refs, hooks=hooks)
44
+
45
+ for h in hooks:
46
+ h.pre_tangle(refs)
47
+
48
+ for tgt in refs.targets:
49
+ result, deps = tangle_ref(refs, tgt, annotation_method)
50
+ mask = next(iter(refs.by_name(tgt))).mode
51
+ t.write(Path(tgt), result, list(map(Path, deps)), mask)
52
+
53
+ for h in hooks:
54
+ h.on_tangle(t, refs)
55
+
56
+ t.clear_orphans()
57
+
58
+ for h in hooks:
59
+ h.post_tangle(refs)
60
+
61
+ except UserError as e:
62
+ logging.error(str(e))
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
- from typing import Iterable
3
- from ..status import find_watch_dirs, list_input_files, list_dependent_files
2
+ from collections.abc import Iterable
3
+ from ..status import list_input_files, list_dependent_files
4
4
  from ..config import config
5
5
  from pathlib import Path
6
6
 
@@ -10,6 +10,7 @@ from rich.table import Table
10
10
  from rich.panel import Panel
11
11
  from rich.tree import Tree
12
12
 
13
+
13
14
  def tree_from_files(files: Iterable[Path]):
14
15
  tree = Tree(label=".")
15
16
  dirs = {Path("."): tree}
@@ -17,13 +18,15 @@ def tree_from_files(files: Iterable[Path]):
17
18
  for p in reversed(f.parents):
18
19
  if p not in dirs:
19
20
  dirs[p] = dirs[p.parent].add(p.name, style="repr.path")
20
- dirs[f.parent].add(f.name, style="repr.filename")
21
+ _ = dirs[f.parent].add(f.name, style="repr.filename")
21
22
  return tree
22
23
 
24
+
23
25
  def files_panel(file_list: Iterable[Path], title: str) -> Panel:
24
26
  tree = tree_from_files(file_list)
25
27
  return Panel(tree, title=title, border_style="dark_cyan")
26
28
 
29
+
27
30
  def rich_status():
28
31
  config_table = Table()
29
32
  config_table.add_column("name")
@@ -6,9 +6,9 @@ import argh # type: ignore
6
6
 
7
7
  from ..config import config
8
8
  from ..document import ReferenceMap, Content, PlainText, ReferenceId
9
- from ..transaction import transaction, TransactionMode
9
+ from ..io import transaction, TransactionMode
10
10
  from ..errors.user import UserError
11
- from .tangle import get_input_files
11
+ from ..config import get_input_files
12
12
 
13
13
 
14
14
  def stitch_markdown(reference_map: ReferenceMap, content: list[Content]) -> str:
@@ -46,17 +46,16 @@ def stitch(*, force: bool = False, show: bool = False):
46
46
  refs = ReferenceMap()
47
47
  content: dict[Path, list[Content]] = {}
48
48
  try:
49
- for path in input_file_list:
50
- logging.debug("reading `%s`", path)
51
- _, c = read_markdown_file(path, refs=refs, hooks=hooks)
52
- content[path] = c
53
-
54
49
  with transaction(mode) as t:
55
- for path in t.db.managed:
50
+ for path in input_file_list:
51
+ logging.debug("reading `%s`", path)
52
+ _, c = read_markdown_file(t, path, refs=refs, hooks=hooks)
53
+ content[path] = c
54
+
55
+ for path in t.db.managed_files:
56
56
  logging.debug("reading `%s`", path)
57
57
  t.update(path)
58
- with open(path, "r") as f:
59
- CodeReader(path, refs).run(f.read())
58
+ _ = CodeReader(path, refs).run(t.read(path))
60
59
 
61
60
  for path in input_file_list:
62
61
  t.write(path, stitch_markdown(refs, content[path]), [])
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
 
5
5
  import logging
6
6
 
7
- from ..filedb import file_db
7
+ from ..io import filedb, FileCache
8
8
  from ..config import config
9
9
  from .stitch import stitch, get_input_files
10
10
  from .tangle import tangle
@@ -15,11 +15,12 @@ def _stitch_then_tangle():
15
15
  tangle()
16
16
 
17
17
 
18
- def sync_action() -> Optional[Callable[[], None]]:
18
+ def sync_action() -> Callable[[], None] | None:
19
19
  input_file_list = get_input_files()
20
+ fs = FileCache()
20
21
 
21
- with file_db(readonly=True) as db:
22
- changed = set(db.changed())
22
+ with filedb(readonly=True) as db:
23
+ changed = set(db.changed_files(fs))
23
24
 
24
25
  if not all(f in db for f in input_file_list):
25
26
  return tangle
@@ -27,17 +28,17 @@ def sync_action() -> Optional[Callable[[], None]]:
27
28
  if not changed:
28
29
  return None
29
30
 
30
- if changed.isdisjoint(db.managed):
31
+ if changed.isdisjoint(db.managed_files):
31
32
  logging.info("Tangling")
32
33
  return tangle
33
34
 
34
- if changed.issubset(db.managed):
35
+ if changed.issubset(db.managed_files):
35
36
  logging.info("Stitching")
36
37
  return _stitch_then_tangle
37
38
 
38
39
  logging.error("changed: %s", [str(p) for p in changed])
39
40
  logging.error(
40
- "Both markdown and code seem to have changed. " "Don't know what to do now."
41
+ "Both markdown and code seem to have changed, don't know what to do now."
41
42
  )
42
43
  return None
43
44
 
@@ -1,25 +1,15 @@
1
- from itertools import chain
2
1
  from pathlib import Path
3
2
 
4
3
  import argh # type: ignore
5
4
  import logging
6
5
 
7
6
  from ..document import ReferenceMap
8
- from ..config import config, AnnotationMethod
9
- from ..transaction import transaction, TransactionMode
7
+ from ..config import config, AnnotationMethod, get_input_files
8
+ from ..io import transaction, TransactionMode
10
9
  from ..hooks import get_hooks
11
10
  from ..errors.user import UserError
12
11
 
13
12
 
14
- def get_input_files() -> list[Path]:
15
- include_file_list = chain.from_iterable(map(Path(".").glob, config.get.watch_list))
16
- input_file_list = [
17
- path for path in include_file_list
18
- if not any(path.match(pat) for pat in config.get.ignore_list)
19
- ]
20
- return input_file_list
21
-
22
-
23
13
  @argh.arg(
24
14
  "-a",
25
15
  "--annotate",
@@ -62,7 +52,7 @@ def tangle(*, annotate: str | None = None, force: bool = False, show: bool = Fal
62
52
  for path in input_file_list:
63
53
  logging.debug("reading `%s`", path)
64
54
  t.update(path)
65
- read_markdown_file(path, refs=refs, hooks=hooks)
55
+ _, _ = read_markdown_file(t, path, refs=refs, hooks=hooks)
66
56
 
67
57
  for h in hooks:
68
58
  h.pre_tangle(refs)
@@ -11,6 +11,7 @@ from copy import copy, deepcopy
11
11
  from enum import StrEnum
12
12
  from pathlib import Path
13
13
  from typing import Any
14
+ from itertools import chain
14
15
 
15
16
  import msgspec
16
17
  from msgspec import Struct, field
@@ -86,7 +87,7 @@ class Config(Struct, dict=True):
86
87
  annotation: AnnotationMethod = AnnotationMethod.STANDARD
87
88
  use_line_directives: bool = False
88
89
  hooks: list[str] = field(default_factory=lambda: ["shebang"])
89
- hook: dict[str, Any] = field(default_factory=dict)
90
+ hook: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportExplicitAny]
90
91
  brei: Program = field(default_factory=Program)
91
92
 
92
93
  language_index: dict[str, Language] = field(default_factory=dict)
@@ -126,10 +127,10 @@ def read_config_from_toml(
126
127
  return None
127
128
  try:
128
129
  with open(path, "rb") as f:
129
- json: Any = tomllib.load(f)
130
+ json: Any = tomllib.load(f) # pyright: ignore[reportExplicitAny]
130
131
  if section is not None:
131
132
  for s in section.split("."):
132
- json = json[s]
133
+ json = json[s] # pyright: ignore[reportAny]
133
134
  return msgspec.convert(json, type=Config, dec_hook=from_str.dec_hook)
134
135
 
135
136
  except ValueError as e:
@@ -162,7 +163,7 @@ class ConfigWrapper(threading.local):
162
163
  @property
163
164
  def get(self) -> Config:
164
165
  if self.config is None:
165
- raise ValueError(f"No config loaded.")
166
+ raise ValueError("No config loaded.")
166
167
  return self.config
167
168
 
168
169
  @contextmanager
@@ -179,10 +180,19 @@ class ConfigWrapper(threading.local):
179
180
 
180
181
  def get_language(self, lang_name: str) -> Language | None:
181
182
  if self.config is None:
182
- raise ValueError(f"No config loaded.")
183
+ raise ValueError("No config loaded.")
183
184
  return self.config.language_index.get(lang_name, None)
184
185
 
185
186
 
186
187
  config = ConfigWrapper()
187
188
  """The `config.config` variable is changed when the `config` module is loaded.
188
189
  Config is read from `entangled.toml` file."""
190
+
191
+
192
+ def get_input_files() -> list[Path]:
193
+ include_file_list = chain.from_iterable(map(Path(".").glob, config.get.watch_list))
194
+ input_file_list = [
195
+ path for path in include_file_list
196
+ if not any(path.match(pat) for pat in config.get.ignore_list)
197
+ ]
198
+ return sorted(input_file_list)
@@ -125,8 +125,10 @@ def content_to_text(r: ReferenceMap, c: Content) -> str:
125
125
  A string, usually not terminated by a newline.
126
126
  """
127
127
  match c:
128
- case PlainText(s): return s
129
- case ReferenceId(): return r.get_codeblock(c).indented_text
128
+ case PlainText(s):
129
+ return s
130
+ case ReferenceId():
131
+ return r.get_codeblock(c).indented_text
130
132
 
131
133
 
132
134
  def document_to_text(r: ReferenceMap, cs: Iterable[Content]) -> str:
@@ -3,7 +3,7 @@ from typing import override
3
3
  from msgspec import Struct
4
4
 
5
5
  from ..document import ReferenceMap, CodeBlock
6
- from ..transaction import Transaction
6
+ from ..io import Transaction
7
7
 
8
8
 
9
9
  @dataclass
@@ -26,23 +26,23 @@ class HookBase:
26
26
  """When prerequisites aren't met, raise PrerequisitesFailed."""
27
27
  pass
28
28
 
29
- def on_read(self, code: CodeBlock):
29
+ def on_read(self, code: CodeBlock): # pyright: ignore[reportUnusedParameter]
30
30
  """Called when the Markdown is being read, before the assembling of
31
31
  the reference map."""
32
32
  pass
33
33
 
34
- def pre_tangle(self, refs: ReferenceMap):
34
+ def pre_tangle(self, refs: ReferenceMap): # pyright: ignore[reportUnusedParameter]
35
35
  """Executed after reading Markdown, but before actually tangling files.
36
36
  This allows the opportunity to add more targets to the reference map.
37
37
  """
38
38
  pass
39
39
 
40
- def on_tangle(self, t: Transaction, refs: ReferenceMap):
40
+ def on_tangle(self, t: Transaction, refs: ReferenceMap): # pyright: ignore[reportUnusedParameter]
41
41
  """Executed after other targets were tangled, but during the same
42
42
  transaction. If you want to write a file, consider doing so as part of
43
43
  the transaction."""
44
44
  pass
45
45
 
46
- def post_tangle(self, refs: ReferenceMap):
46
+ def post_tangle(self, refs: ReferenceMap): # pyright: ignore[reportUnusedParameter]
47
47
  """Called after the tangle transaction is finished."""
48
48
  pass
@@ -97,7 +97,7 @@ class Hook(HookBase):
97
97
  refs.index[script_file_name].append(ref)
98
98
  refs.targets.add(script_file_name)
99
99
 
100
- deps = (get_attribute(cb.properties, "deps") or "").split()
100
+ deps = [str(s) for s in (get_attribute(cb.properties, "deps") or "").split()]
101
101
  self.recipes.append(Hook.Recipe(target, deps, cb.language, script_file_name))
102
102
 
103
103
  @override
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ import re
3
+ import textwrap
4
+ from typing import Any, final, override
5
+ import yaml
6
+
7
+ from entangled.config.language import Language
8
+
9
+ from ..properties import Attribute, Class, Id, Property
10
+ from ..document import CodeBlock
11
+ from .base import HookBase
12
+ from ..logging import logger
13
+
14
+
15
+ log = logger()
16
+
17
+
18
+ def split_yaml_header(language: Language, source: str) -> tuple[str, str, object]:
19
+ """Split source into YAML header and body."""
20
+ trigger = re.escape(language.comment.open) + r"\s*\|(.*)"
21
+ lines = source.splitlines()
22
+ header_lines: list[str] = []
23
+ body_start: int = 0
24
+
25
+ for i, line in enumerate(lines):
26
+ if m := re.match(trigger, line):
27
+ header_lines.append(m.group(1))
28
+ continue
29
+
30
+ body_start = i
31
+ break
32
+
33
+ return "\n".join(lines[:body_start]), "\n".join(lines[body_start:]), yaml.safe_load("\n".join(header_lines))
34
+
35
+
36
+ def amend_code_properties(code_block: CodeBlock):
37
+ language = code_block.language
38
+ if not language:
39
+ return
40
+
41
+ source = code_block.source
42
+ props: list[Property] = []
43
+
44
+ header, body, attrs = split_yaml_header(language, source)
45
+
46
+ if attrs is None:
47
+ return
48
+
49
+ if not isinstance(attrs, dict):
50
+ log.warning(f"{code_block.origin}: Quarto attributes do not evaluate to dictionary; skipping")
51
+ log.warning(f"tried to parse:\n{header}")
52
+ return
53
+
54
+ if "id" in attrs.keys():
55
+ if not isinstance(attrs["id"], str):
56
+ log.warning(f"{code_block.origin}: Quarto id does not evaluate to string; skipping")
57
+ log.warning(f"tried to parse:\n{header}")
58
+ return
59
+ props.append(Id(attrs["id"]))
60
+
61
+ if "classes" in attrs.keys():
62
+ classes = attrs["classes"]
63
+ if not all(isinstance(c, str) for c in classes):
64
+ log.warning(f"{code_block.origin}: Quarto classes do not evaluate to strings; skipping")
65
+ log.warning(f"tried to parse:\n{header}")
66
+ return
67
+ props.extend(Class(c) for c in classes)
68
+
69
+ props.extend(Attribute(str(k), v) for k, v in attrs.items()
70
+ if k not in ("id", "classes"))
71
+
72
+ code_block.source = body
73
+ code_block.open_line += "\n" + header
74
+ code_block.properties.extend(props)
75
+
76
+
77
+ @final
78
+ class Hook(HookBase):
79
+ def __init__(self, config: Hook.Config):
80
+ super().__init__(config)
81
+ self.config = config
82
+
83
+ @override
84
+ def on_read(self, code: CodeBlock):
85
+ log.debug("quarto filter: %s", code)
86
+ amend_code_properties(code)
@@ -10,7 +10,7 @@ from msgspec import Struct, field
10
10
  from pathlib import Path
11
11
 
12
12
  from ..logging import logger
13
- from ..transaction import Transaction
13
+ from ..io import Transaction
14
14
  from ..document import CodeBlock, ReferenceMap
15
15
  from ..properties import Class, get_attribute, get_id
16
16
 
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Any, final, override
7
7
 
8
8
  from ..config import AnnotationMethod
9
- from ..transaction import Transaction
9
+ from ..io import Transaction
10
10
 
11
11
  from ..document import CodeBlock, ReferenceId, ReferenceMap
12
12
  from ..properties import Class, Property, get_attribute, get_classes
@@ -0,0 +1,11 @@
1
+ """
2
+ In Entangled all file IO should pass through a transaction.
3
+ """
4
+
5
+
6
+ from .transaction import transaction, Transaction, TransactionMode
7
+ from .filedb import filedb
8
+ from .virtual import FileCache
9
+
10
+
11
+ __all__ = ["FileCache", "filedb", "Transaction", "TransactionMode", "transaction"]