entangled-cli 2.2.1__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/PKG-INFO +1 -1
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/code_reader.py +1 -1
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/__init__.py +2 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/brei.py +6 -6
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/new.py +7 -8
- entangled_cli-2.3.0/entangled/commands/reset.py +62 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/status.py +6 -3
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/stitch.py +3 -3
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/sync.py +8 -7
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/tangle.py +3 -13
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/config/__init__.py +15 -5
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/document.py +4 -2
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/hooks/base.py +5 -5
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/hooks/build.py +1 -1
- entangled_cli-2.3.0/entangled/hooks/quarto_attributes.py +86 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/hooks/repl.py +1 -1
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/hooks/task.py +1 -1
- entangled_cli-2.3.0/entangled/io/__init__.py +11 -0
- entangled_cli-2.3.0/entangled/io/filedb.py +124 -0
- entangled_cli-2.3.0/entangled/io/stat.py +70 -0
- entangled_cli-2.3.0/entangled/io/transaction.py +255 -0
- entangled_cli-2.3.0/entangled/io/virtual.py +99 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/logging.py +4 -4
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/main.py +2 -2
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/parsing.py +50 -59
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/properties.py +22 -30
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/status.py +5 -5
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/tangle.py +7 -5
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/pyproject.toml +2 -2
- entangled_cli-2.2.1/entangled/filedb.py +0 -207
- entangled_cli-2.2.1/entangled/hooks/quarto_attributes.py +0 -61
- entangled_cli-2.2.1/entangled/transaction.py +0 -278
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/.gitignore +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/LICENSE +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/README.md +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/__init__.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/commands/watch.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/config/language.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/config/templates.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/config/version.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/errors/__init__.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/errors/internal.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/errors/user.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/from_str.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/hooks/__init__.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/hooks/shebang.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/hooks/spdx_license.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/markdown_reader.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/py.typed +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/text_location.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/utility.py +0 -0
- {entangled_cli-2.2.1 → entangled_cli-2.3.0}/entangled/version.py +0 -0
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
100
|
-
project_path:
|
|
101
|
-
answers_file:
|
|
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(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
|
|
3
|
-
from ..status import
|
|
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 ..
|
|
9
|
+
from ..io import transaction, TransactionMode
|
|
10
10
|
from ..errors.user import UserError
|
|
11
|
-
from
|
|
11
|
+
from ..config import get_input_files
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def stitch_markdown(reference_map: ReferenceMap, content: list[Content]) -> str:
|
|
@@ -52,7 +52,7 @@ def stitch(*, force: bool = False, show: bool = False):
|
|
|
52
52
|
content[path] = c
|
|
53
53
|
|
|
54
54
|
with transaction(mode) as t:
|
|
55
|
-
for path in t.db.
|
|
55
|
+
for path in t.db.managed_files:
|
|
56
56
|
logging.debug("reading `%s`", path)
|
|
57
57
|
t.update(path)
|
|
58
58
|
with open(path, "r") as f:
|
|
@@ -4,7 +4,7 @@ from pathlib import Path
|
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
6
|
|
|
7
|
-
from ..
|
|
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() ->
|
|
18
|
+
def sync_action() -> Callable[[], None] | None:
|
|
19
19
|
input_file_list = get_input_files()
|
|
20
|
+
fs = FileCache()
|
|
20
21
|
|
|
21
|
-
with
|
|
22
|
-
changed = set(db.
|
|
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.
|
|
31
|
+
if changed.isdisjoint(db.managed_files):
|
|
31
32
|
logging.info("Tangling")
|
|
32
33
|
return tangle
|
|
33
34
|
|
|
34
|
-
if changed.issubset(db.
|
|
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
|
|
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 ..
|
|
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(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(
|
|
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(
|
|
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):
|
|
129
|
-
|
|
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 ..
|
|
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 ..
|
|
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 ..
|
|
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"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from collections.abc import Generator
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import msgspec
|
|
9
|
+
from msgspec import Struct
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from filelock import FileLock
|
|
14
|
+
|
|
15
|
+
from entangled.errors.user import HelpfulUserError
|
|
16
|
+
|
|
17
|
+
from ..version import __version__
|
|
18
|
+
from ..utility import normal_relative, ensure_parent
|
|
19
|
+
from .virtual import FileCache
|
|
20
|
+
from .stat import Stat, hexdigest
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FileDB(Struct):
|
|
24
|
+
"""Persistent storage for file stats of both Markdown and generated
|
|
25
|
+
files. We can use this to detect conflicts.
|
|
26
|
+
|
|
27
|
+
This data is stored in `.entangled/files.json`. It is recommended to
|
|
28
|
+
keep this file under version control. That way entangled shouldn't get
|
|
29
|
+
too confused when switching branches.
|
|
30
|
+
|
|
31
|
+
All files are stored in a single dictionary, the distinction between
|
|
32
|
+
source and target files is made in two separate indices."""
|
|
33
|
+
|
|
34
|
+
version: str
|
|
35
|
+
files: dict[str, Stat]
|
|
36
|
+
targets: set[str]
|
|
37
|
+
|
|
38
|
+
def clear(self):
|
|
39
|
+
self.files = {}
|
|
40
|
+
self.targets = set()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def managed_files(self) -> set[Path]:
|
|
44
|
+
"""List all managed files. These are files that can be reconstructed
|
|
45
|
+
from the sources, at least when things are in a consistent state.
|
|
46
|
+
|
|
47
|
+
For example: markdown sources cannot be reconstructed, so are not
|
|
48
|
+
listed here. However, generated code is first constructed from
|
|
49
|
+
the markdown, so is considered to be managed."""
|
|
50
|
+
return {Path(p) for p in self.targets}
|
|
51
|
+
|
|
52
|
+
def changed_files(self, fs: FileCache) -> Generator[Path]:
|
|
53
|
+
return (Path(p) for p, known_stat in self.files.items()
|
|
54
|
+
if fs[Path(p)].stat != known_stat)
|
|
55
|
+
|
|
56
|
+
def create_target(self, fs: FileCache, path: Path):
|
|
57
|
+
path = normal_relative(path)
|
|
58
|
+
self.update(fs, path)
|
|
59
|
+
self.targets.add(path.as_posix())
|
|
60
|
+
|
|
61
|
+
def update(self, fs: FileCache, path: Path):
|
|
62
|
+
path = normal_relative(path)
|
|
63
|
+
self.files[path.as_posix()] = fs[path].stat
|
|
64
|
+
|
|
65
|
+
def __contains__(self, path: Path) -> bool:
|
|
66
|
+
return path.as_posix() in self.files
|
|
67
|
+
|
|
68
|
+
def __getitem__(self, path: Path) -> Stat:
|
|
69
|
+
return self.files[path.as_posix()]
|
|
70
|
+
|
|
71
|
+
def __delitem__(self, path: Path):
|
|
72
|
+
path_str = path.as_posix()
|
|
73
|
+
if path_str in self.targets:
|
|
74
|
+
self.targets.remove(path_str)
|
|
75
|
+
del self.files[path_str]
|
|
76
|
+
|
|
77
|
+
def __iter__(self):
|
|
78
|
+
return (Path(p) for p in self.files)
|
|
79
|
+
|
|
80
|
+
def check(self, path: Path, content: str) -> bool:
|
|
81
|
+
return hexdigest(content) == self.files[path.as_posix()].hexdigest
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
FILEDB_PATH = Path(".") / ".entangled" / "filedb.json"
|
|
85
|
+
FILEDB_LOCK_PATH = Path(".") / ".entangled" / "filedb.lock"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def new_db() -> FileDB:
|
|
89
|
+
return FileDB(__version__, {}, set())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_filedb() -> FileDB:
|
|
93
|
+
if not FILEDB_PATH.exists():
|
|
94
|
+
return new_db()
|
|
95
|
+
|
|
96
|
+
logging.debug("Reading FileDB")
|
|
97
|
+
raw: Any = json.load(open(FILEDB_PATH, "rb")) # pyright: ignore[reportExplicitAny, reportAny]
|
|
98
|
+
if raw["version"] != __version__:
|
|
99
|
+
raise HelpfulUserError(
|
|
100
|
+
f"File database was created with a different version of Entangled ({raw["version"]}).\n" +
|
|
101
|
+
f"Run `entangled reset` to regenerate the database to version {__version__}.")
|
|
102
|
+
|
|
103
|
+
db = msgspec.convert(raw, type=FileDB)
|
|
104
|
+
|
|
105
|
+
undead = list(filter(lambda p: not p.exists(), db))
|
|
106
|
+
for path in undead:
|
|
107
|
+
logging.warning(f"undead file `{path}` (found in db but not on drive)")
|
|
108
|
+
|
|
109
|
+
return db
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_filedb(db: FileDB):
|
|
113
|
+
logging.debug("Writing FileDB")
|
|
114
|
+
_ = FILEDB_PATH.open("wb").write(msgspec.json.encode(db, order="sorted"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@contextmanager
|
|
118
|
+
def filedb(readonly: bool = False, writeonly: bool = False):
|
|
119
|
+
lock = FileLock(ensure_parent(FILEDB_LOCK_PATH))
|
|
120
|
+
with lock:
|
|
121
|
+
db = read_filedb() if not writeonly else new_db()
|
|
122
|
+
yield db
|
|
123
|
+
if not readonly:
|
|
124
|
+
write_filedb(db)
|