entangled-cli 2.1.13__tar.gz → 2.2.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 (49) hide show
  1. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/PKG-INFO +7 -3
  2. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/README.md +2 -0
  3. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/code_reader.py +7 -6
  4. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/status.py +3 -4
  5. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/stitch.py +2 -3
  6. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/tangle.py +4 -5
  7. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/watch.py +1 -4
  8. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/config/__init__.py +46 -37
  9. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/config/language.py +6 -8
  10. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/config/templates.py +2 -3
  11. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/config/version.py +5 -3
  12. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/document.py +70 -8
  13. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/errors/user.py +11 -2
  14. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/filedb.py +23 -11
  15. entangled_cli-2.2.0/entangled/from_str.py +27 -0
  16. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/hooks/__init__.py +14 -10
  17. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/hooks/base.py +6 -6
  18. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/hooks/build.py +11 -5
  19. entangled_cli-2.2.0/entangled/hooks/quarto_attributes.py +61 -0
  20. entangled_cli-2.2.0/entangled/hooks/repl.py +86 -0
  21. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/hooks/shebang.py +3 -1
  22. entangled_cli-2.2.0/entangled/hooks/spdx_license.py +12 -0
  23. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/hooks/task.py +5 -5
  24. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/logging.py +1 -1
  25. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/main.py +6 -4
  26. entangled_cli-2.2.0/entangled/markdown_reader.py +228 -0
  27. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/parsing.py +9 -5
  28. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/properties.py +5 -4
  29. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/status.py +4 -3
  30. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/tangle.py +19 -14
  31. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/text_location.py +3 -2
  32. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/transaction.py +30 -13
  33. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/utility.py +5 -10
  34. entangled_cli-2.2.0/pyproject.toml +67 -0
  35. entangled_cli-2.1.13/entangled/construct.py +0 -167
  36. entangled_cli-2.1.13/entangled/hooks/quarto_attributes.py +0 -51
  37. entangled_cli-2.1.13/entangled/markdown_reader.py +0 -168
  38. entangled_cli-2.1.13/pyproject.toml +0 -60
  39. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/.gitignore +0 -0
  40. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/LICENSE +0 -0
  41. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/__init__.py +0 -0
  42. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/__init__.py +0 -0
  43. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/brei.py +0 -0
  44. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/new.py +0 -0
  45. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/commands/sync.py +0 -0
  46. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/errors/__init__.py +0 -0
  47. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/errors/internal.py +0 -0
  48. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/py.typed +0 -0
  49. {entangled_cli-2.1.13 → entangled_cli-2.2.0}/entangled/version.py +0 -0
@@ -1,20 +1,22 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: entangled-cli
3
- Version: 2.1.13
3
+ Version: 2.2.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
7
7
  Author-email: Johan Hidding <j.hidding@esciencecenter.nl>
8
8
  License-Expression: Apache-2.0
9
9
  License-File: LICENSE
10
- Requires-Python: ~=3.11
10
+ Requires-Python: <4,>=3.12
11
11
  Requires-Dist: argh<0.31,>=0.30
12
12
  Requires-Dist: brei<0.3,>=0.2.3
13
13
  Requires-Dist: copier<10,>=9
14
14
  Requires-Dist: filelock<4,>=3.12.0
15
- Requires-Dist: mawk<0.2,>=0.1.4
15
+ Requires-Dist: mawk<0.2,>=0.1.7
16
+ Requires-Dist: msgspec>=0.19.0
16
17
  Requires-Dist: pexpect<5,>=4.9.0
17
18
  Requires-Dist: pyyaml<7,>=6.0.1
19
+ Requires-Dist: repl-session>=0.2.0
18
20
  Requires-Dist: rich-argparse<2,>=1.4.0
19
21
  Requires-Dist: rich<14,>=13.3.5
20
22
  Requires-Dist: tomlkit<0.13,>=0.12.1
@@ -218,6 +220,8 @@ Entangled has a system of *hooks*: these add actions to the tangling process:
218
220
  - `brei` trigger actions (or tasks) using [Brei](https://entangled.github.io/brei), which is automatically installed along with Entangled. This is now prefered over the `build` hook.
219
221
  - `quarto_attributes` add attributes to the code block in Quatro style with `#|` comments at the top of the code block.
220
222
  - `shebang` takes the first line if it starts with `#!` and puts it at the top of the file.
223
+ - `spdx_license` takes the first line if it contains `SPDX-License-Identifier`
224
+ and puts it at the top of the file.
221
225
 
222
226
  ### `build` hook
223
227
 
@@ -195,6 +195,8 @@ Entangled has a system of *hooks*: these add actions to the tangling process:
195
195
  - `brei` trigger actions (or tasks) using [Brei](https://entangled.github.io/brei), which is automatically installed along with Entangled. This is now prefered over the `build` hook.
196
196
  - `quarto_attributes` add attributes to the code block in Quatro style with `#|` comments at the top of the code block.
197
197
  - `shebang` takes the first line if it starts with `#!` and puts it at the top of the file.
198
+ - `spdx_license` takes the first line if it contains `SPDX-License-Identifier`
199
+ and puts it at the top of the file.
198
200
 
199
201
  ### `build` hook
200
202
 
@@ -4,7 +4,8 @@ from pathlib import Path, PurePath
4
4
  import mawk
5
5
  import re
6
6
 
7
- from .document import ReferenceId, TextLocation, ReferenceMap
7
+ from .text_location import TextLocation
8
+ from .document import ReferenceId, ReferenceMap
8
9
  from .errors.user import IndentationError
9
10
 
10
11
 
@@ -19,7 +20,7 @@ class CodeReader(mawk.RuleSet):
19
20
  """Reads an annotated code file."""
20
21
 
21
22
  def __init__(self, path: PurePath, refs: ReferenceMap):
22
- self.location = TextLocation(path, 0)
23
+ self.location: TextLocation = TextLocation(path, 0)
23
24
  self.stack: list[Frame] = [Frame(ReferenceId("#root#", PurePath("-"), -1), "")]
24
25
  self.refs: ReferenceMap = refs
25
26
 
@@ -34,7 +35,7 @@ class CodeReader(mawk.RuleSet):
34
35
  @mawk.on_match(
35
36
  r"^(?P<indent>\s*).* ~/~ begin <<(?P<source>[^#<>]+)#(?P<ref_name>[^#<>]+)>>\[(?P<ref_count>init|\d+)\]"
36
37
  )
37
- def on_block_begin(self, m: re.Match):
38
+ def on_block_begin(self, m: re.Match[str]) -> list[str]:
38
39
  ref_name = m["ref_name"]
39
40
 
40
41
  # When there are lines above the first ref, say a shebang, swap
@@ -62,15 +63,15 @@ class CodeReader(mawk.RuleSet):
62
63
  return []
63
64
 
64
65
  @mawk.on_match(r"^(?P<indent>\s*).* ~/~ end")
65
- def on_block_end(self, m: re.Match):
66
+ def on_block_end(self, m: re.Match[str]) -> list[str]:
66
67
  if m["indent"] != self.current.indent:
67
68
  raise IndentationError(self.location)
68
69
  self.refs[self.current.ref].source = "\n".join(self.current.content)
69
- self.stack.pop()
70
+ _ = self.stack.pop()
70
71
  return []
71
72
 
72
73
  @mawk.always
73
- def otherwise(self, line: str):
74
+ def otherwise(self, line: str) -> list[str]:
74
75
  if line.strip() == "":
75
76
  self.current.content.append("")
76
77
  return []
@@ -29,12 +29,12 @@ def rich_status():
29
29
  config_table.add_column("name")
30
30
  config_table.add_column("value")
31
31
  config_table.add_row(
32
- "Watch list", ", ".join(f"'{pat}'" for pat in config.watch_list)
32
+ "Watch list", ", ".join(f"'{pat}'" for pat in config.get.watch_list)
33
33
  )
34
34
  config_table.add_row(
35
- "Ignore list", ", ".join(f"'{pat}'" for pat in config.ignore_list)
35
+ "Ignore list", ", ".join(f"'{pat}'" for pat in config.get.ignore_list)
36
36
  )
37
- config_table.add_row("Hooks enabled", ", ".join(config.hooks))
37
+ config_table.add_row("Hooks enabled", ", ".join(config.get.hooks))
38
38
 
39
39
  console = Console(color_system="auto")
40
40
  group = Group(
@@ -53,4 +53,3 @@ def rich_status():
53
53
  def status():
54
54
  config.read()
55
55
  rich_status()
56
-
@@ -1,6 +1,5 @@
1
1
  from itertools import chain
2
2
  from pathlib import Path
3
- from textwrap import indent
4
3
 
5
4
  import logging
6
5
  import argh # type: ignore
@@ -18,7 +17,7 @@ def stitch_markdown(reference_map: ReferenceMap, content: list[Content]) -> str:
18
17
  case PlainText(s):
19
18
  return s
20
19
  case ReferenceId():
21
- return indent(reference_map[item].source, reference_map[item].indent)
20
+ return reference_map.get_codeblock(item).indented_text
22
21
 
23
22
  return "\n".join(get(i) for i in content) + "\n"
24
23
 
@@ -33,7 +32,7 @@ def stitch(*, force: bool = False, show: bool = False):
33
32
  from ..markdown_reader import read_markdown_file
34
33
  from ..code_reader import CodeReader
35
34
  from ..hooks import get_hooks
36
-
35
+
37
36
  input_file_list = get_input_files()
38
37
  hooks = get_hooks()
39
38
 
@@ -1,4 +1,3 @@
1
- from typing import Optional
2
1
  from itertools import chain
3
2
  from pathlib import Path
4
3
 
@@ -13,10 +12,10 @@ from ..errors.user import UserError
13
12
 
14
13
 
15
14
  def get_input_files() -> list[Path]:
16
- include_file_list = chain.from_iterable(map(Path(".").glob, config.watch_list))
15
+ include_file_list = chain.from_iterable(map(Path(".").glob, config.get.watch_list))
17
16
  input_file_list = [
18
17
  path for path in include_file_list
19
- if not any(path.match(pat) for pat in config.ignore_list)
18
+ if not any(path.match(pat) for pat in config.get.ignore_list)
20
19
  ]
21
20
  return input_file_list
22
21
 
@@ -30,7 +29,7 @@ def get_input_files() -> list[Path]:
30
29
  @argh.arg("--force", help="force overwrite on conflict")
31
30
  @argh.arg("-s", "--show", help="only show, don't act")
32
31
  @argh.arg("-r", "--reset-db", help="resets database")
33
- def tangle(*, annotate: Optional[str] = None, force: bool = False, show: bool = False, reset_db = False):
32
+ def tangle(*, annotate: str | None = None, force: bool = False, show: bool = False, reset_db: bool = False):
34
33
  """Tangle codes from Markdown"""
35
34
  config.read()
36
35
 
@@ -39,7 +38,7 @@ def tangle(*, annotate: Optional[str] = None, force: bool = False, show: bool =
39
38
  from ..tangle import tangle_ref
40
39
 
41
40
  if annotate is None:
42
- annotation_method = config.annotation
41
+ annotation_method = config.get.annotation
43
42
  else:
44
43
  annotation_method = AnnotationMethod[annotate.upper()]
45
44
 
@@ -1,8 +1,5 @@
1
- from typing import Optional
2
1
  from pathlib import Path
3
- from itertools import chain
4
2
  from threading import Event
5
- import os
6
3
 
7
4
  from watchdog.observers import Observer
8
5
  from watchdog.events import FileSystemEventHandler, FileSystemEvent
@@ -32,7 +29,7 @@ class EventHandler(FileSystemEventHandler):
32
29
  self.update_watched()
33
30
 
34
31
 
35
- def _watch(_stop_event: Optional[Event] = None, _start_event: Optional[Event] = None):
32
+ def _watch(_stop_event: Event | None = None, _start_event: Event | None = None):
36
33
  """Keep a loop running, watching for changes. This interface is separated
37
34
  from the CLI one, so that it can be tested using threading instead of
38
35
  subprocess."""
@@ -4,21 +4,21 @@ defaults and config loaded from `entangled.toml` in the work directory.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
+ from functools import cached_property
7
8
  import threading
8
9
  from contextlib import contextmanager
9
- from copy import copy
10
- from dataclasses import dataclass, field
11
- from enum import Enum
10
+ from copy import copy, deepcopy
11
+ from enum import StrEnum
12
12
  from pathlib import Path
13
- from typing import Any, ClassVar, Optional, TypeVar
13
+ from typing import Any
14
14
 
15
+ import msgspec
16
+ from msgspec import Struct, field
15
17
  import tomllib
16
- from entangled.errors.internal import InternalError
17
-
18
- from entangled.errors.user import UserError
19
18
 
20
19
  from brei import Program
21
- from ..construct import construct
20
+
21
+ from entangled import from_str
22
22
  from .language import Language, languages
23
23
  from .version import Version
24
24
  from ..logging import logger
@@ -27,7 +27,7 @@ from ..logging import logger
27
27
  log = logger()
28
28
 
29
29
 
30
- class AnnotationMethod(Enum):
30
+ class AnnotationMethod(StrEnum):
31
31
  """Annotation methods.
32
32
 
33
33
  - `STANDARD` is the default. Comments tell where a piece of code
@@ -38,13 +38,12 @@ class AnnotationMethod(Enum):
38
38
  - `SUPPLEMENTED` adds extra information to the comment lines.
39
39
  """
40
40
 
41
- STANDARD = 1
42
- NAKED = 2
43
- SUPPLEMENTED = 3
41
+ STANDARD = "standard"
42
+ NAKED = "naked"
43
+ SUPPLEMENTED = "supplemented"
44
44
 
45
45
 
46
- @dataclass
47
- class Markers:
46
+ class Markers(Struct):
48
47
  """Markers can be used to configure the Markdown dialect. Currently not used."""
49
48
 
50
49
  open: str
@@ -58,8 +57,7 @@ markers = Markers(
58
57
  )
59
58
 
60
59
 
61
- @dataclass
62
- class Config(threading.local):
60
+ class Config(Struct, dict=True):
63
61
  """Main config class.
64
62
 
65
63
  Attributes:
@@ -79,35 +77,40 @@ class Config(threading.local):
79
77
 
80
78
  This class is made thread-local to make it possible to test in parallel."""
81
79
 
82
- version: Version
80
+ _version: str = field(name = "version")
83
81
  languages: list[Language] = field(default_factory=list)
84
82
  markers: Markers = field(default_factory=lambda: copy(markers))
85
83
  watch_list: list[str] = field(default_factory=lambda: ["**/*.md"])
86
84
  ignore_list: list[str] = field(default_factory=list)
87
- annotation_format: Optional[str] = None
85
+ annotation_format: str | None = None
88
86
  annotation: AnnotationMethod = AnnotationMethod.STANDARD
89
87
  use_line_directives: bool = False
90
88
  hooks: list[str] = field(default_factory=lambda: ["shebang"])
91
- hook: dict = field(default_factory=dict)
89
+ hook: dict[str, Any] = field(default_factory=dict)
92
90
  brei: Program = field(default_factory=Program)
93
91
 
92
+ language_index: dict[str, Language] = field(default_factory=dict)
93
+
94
+ @cached_property
95
+ def version(self) -> Version:
96
+ return Version.from_str(self._version)
97
+
94
98
  def __post_init__(self):
95
99
  self.languages = languages + self.languages
96
100
  self.make_language_index()
97
101
 
98
102
  def make_language_index(self):
99
- self.language_index = dict()
100
103
  for l in self.languages:
101
104
  for i in l.identifiers:
102
105
  self.language_index[i] = l
103
106
 
104
107
 
105
- default = Config(Version.from_str("2.0"))
108
+ default = Config("2.0") # Version.from_str("2.0"))
106
109
 
107
110
 
108
111
  def read_config_from_toml(
109
- path: Path, section: Optional[str] = None
110
- ) -> Optional[Config]:
112
+ path: Path, section: str | None = None
113
+ ) -> Config | None:
111
114
  """Read a config from given `path` in given `section`. The path should refer to
112
115
  a TOML file that should decode to a `Config` object. If `section` is given, only
113
116
  that section is decoded to a `Config` object. The `section` string may contain
@@ -127,7 +130,8 @@ def read_config_from_toml(
127
130
  if section is not None:
128
131
  for s in section.split("."):
129
132
  json = json[s]
130
- return construct(Config, json)
133
+ return msgspec.convert(json, type=Config, dec_hook=from_str.dec_hook)
134
+
131
135
  except ValueError as e:
132
136
  log.error("Could not read config: %s", e)
133
137
  return None
@@ -147,30 +151,35 @@ def read_config():
147
151
  return default
148
152
 
149
153
 
150
- class ConfigWrapper:
151
- def __init__(self, config = None):
152
- self.config = config
154
+ class ConfigWrapper(threading.local):
155
+ def __init__(self, config: Config | None = None):
156
+ self.config: Config | None = config
153
157
 
154
- def read(self, force=False):
158
+ def read(self, force: bool = False):
155
159
  if self.config is None or force:
156
160
  self.config = read_config()
157
161
 
158
- def __getattr__(self, attr):
162
+ @property
163
+ def get(self) -> Config:
159
164
  if self.config is None:
160
- raise InternalError("Config not loaded.")
161
- return getattr(self.config, attr)
165
+ raise ValueError(f"No config loaded.")
166
+ return self.config
162
167
 
163
168
  @contextmanager
164
169
  def __call__(self, **kwargs):
165
- backup = {k: getattr(self.config, k) for k in kwargs}
170
+ backup = self.config
171
+ new_config = deepcopy(self.config)
166
172
  for k, v in kwargs.items():
167
- setattr(self.config, k, v)
173
+ setattr(new_config, k, v)
174
+ self.config = new_config
175
+
168
176
  yield
169
- for k in kwargs.keys():
170
- setattr(self.config, k, backup[k])
171
177
 
172
- def get_language(self, lang_name: str) -> Optional[Language]:
173
- assert self.config
178
+ self.config = backup
179
+
180
+ def get_language(self, lang_name: str) -> Language | None:
181
+ if self.config is None:
182
+ raise ValueError(f"No config loaded.")
174
183
  return self.config.language_index.get(lang_name, None)
175
184
 
176
185
 
@@ -1,19 +1,16 @@
1
- from typing import Optional
2
- from dataclasses import dataclass
1
+ from msgspec import Struct
3
2
 
4
3
 
5
- @dataclass
6
- class Comment:
4
+ class Comment(Struct):
7
5
  """Comment method for a language. For example: `Comment("/*", "*/")` works
8
6
  for C/C++ etc, `Comment("#")` works for Python, and so on.
9
7
  """
10
8
 
11
9
  open: str
12
- close: Optional[str] = None
10
+ close: str | None = None
13
11
 
14
12
 
15
- @dataclass
16
- class Language:
13
+ class Language(Struct):
17
14
  """Language information. Given a language we may have any number of short-hands
18
15
  to indicate a code block is written in that language. If a language supports
19
16
  line directives this can be used to redirect compiler messages directly to the
@@ -22,7 +19,7 @@ class Language:
22
19
  name: str
23
20
  identifiers: list[str]
24
21
  comment: Comment
25
- line_directive: Optional[str] = None
22
+ line_directive: str | None = None
26
23
 
27
24
 
28
25
  languages = [
@@ -43,6 +40,7 @@ languages = [
43
40
  Language("Make", ["make", "makefile"], Comment("#")),
44
41
  Language("Gnuplot", ["gnuplot"], Comment("#")),
45
42
  Language("TOML", ["toml"], Comment("#")),
43
+ Language("YAML", ["yaml"], Comment("#")),
46
44
  Language("F#", ["fsharp"], Comment("//")),
47
45
  Language("Javascript", ["javascript", "js", "ecma"], Comment("//")),
48
46
  Language("Go", ["go", "golang"], Comment("//")),
@@ -1,8 +1,7 @@
1
- from dataclasses import dataclass
1
+ from msgspec import Struct
2
2
 
3
3
 
4
- @dataclass
5
- class Template:
4
+ class Template(Struct):
6
5
  """Officially supported templates. Templates are pulled from external GitHub
7
6
  repositories: `entangled new <template name>`. These templates are the ones
8
7
  we officially support. Other templates can be used with valid copier URL
@@ -1,16 +1,18 @@
1
1
  from __future__ import annotations
2
2
  from dataclasses import dataclass
3
+ from ..from_str import FromStr
4
+ from typing import override
3
5
 
4
- from ..construct import FromStr
5
6
 
6
7
  @dataclass
7
8
  class Version(FromStr):
8
9
  numbers: tuple[int, ...]
9
10
 
10
- def __str__(self):
11
+ @override
12
+ def to_str(self) -> str:
11
13
  return ".".join(str(i) for i in self.numbers)
12
14
 
15
+ @override
13
16
  @classmethod
14
17
  def from_str(cls, s: str) -> Version:
15
18
  return Version(tuple(int(sv) for sv in s.split(".")))
16
-
@@ -1,4 +1,5 @@
1
- from typing import Optional, Union, Iterable, Any
1
+ from textwrap import indent
2
+ from typing import Optional, Union, Iterable, Any, override
2
3
  from dataclasses import dataclass, field
3
4
  from collections import defaultdict
4
5
  from functools import singledispatchmethod
@@ -11,7 +12,7 @@ from .errors.internal import InternalError
11
12
  from .text_location import TextLocation
12
13
 
13
14
 
14
- def length(iter: Iterable[Any]) -> int:
15
+ def length[T](iter: Iterable[T]) -> int:
15
16
  return sum(1 for _ in iter)
16
17
 
17
18
 
@@ -21,7 +22,8 @@ class ReferenceId:
21
22
  file: PurePath
22
23
  ref_count: int
23
24
 
24
- def __hash__(self):
25
+ @override
26
+ def __hash__(self) -> int:
25
27
  return hash((self.name, self.file, self.ref_count))
26
28
 
27
29
 
@@ -34,15 +36,25 @@ class PlainText:
34
36
  class CodeBlock:
35
37
  properties: list[Property]
36
38
  indent: str
39
+ open_line: str
40
+ close_line: str
37
41
  source: str
38
42
  origin: TextLocation
39
- language: Optional[Language] = None
40
- header: Optional[str] = None
41
- mode: Optional[int] = None
43
+ language: Language | None = None
44
+ header: str | None = None
45
+ mode: int | None = None
42
46
 
47
+ @property
48
+ def text(self) -> str:
49
+ return self.open_line + "\n" + self.source + "\n" + self.close_line
43
50
 
44
- Content = Union[PlainText, ReferenceId]
45
- RawContent = Union[PlainText, CodeBlock]
51
+ @property
52
+ def indented_text(self) -> str:
53
+ return indent(self.text, self.indent)
54
+
55
+
56
+ Content = PlainText | ReferenceId
57
+ RawContent = PlainText | CodeBlock
46
58
 
47
59
 
48
60
  @dataclass
@@ -85,6 +97,9 @@ class ReferenceMap:
85
97
  def __contains__(self, key: str) -> bool:
86
98
  return key in self.index
87
99
 
100
+ def get_codeblock(self, key: ReferenceId) -> CodeBlock:
101
+ return self.map[key]
102
+
88
103
  @singledispatchmethod
89
104
  def __getitem__(self, key):
90
105
  raise NotImplementedError(f"Invalid key: {type(key)}")
@@ -96,3 +111,50 @@ class ReferenceMap:
96
111
  @__getitem__.register
97
112
  def _(self, key: str) -> Iterable[CodeBlock]:
98
113
  return self.by_name(key)
114
+
115
+
116
+ def content_to_text(r: ReferenceMap, c: Content) -> str:
117
+ """
118
+ Reconstruct original plain text from a piece of content.
119
+
120
+ Args:
121
+ r: the reference map.
122
+ c: the content.
123
+
124
+ Returns:
125
+ A string, usually not terminated by a newline.
126
+ """
127
+ match c:
128
+ case PlainText(s): return s
129
+ case ReferenceId(): return r.get_codeblock(c).indented_text
130
+
131
+
132
+ def document_to_text(r: ReferenceMap, cs: list[Content]) -> str:
133
+ """
134
+ Reconstruct original plain text content from a reference map and
135
+ list of content.
136
+
137
+ Args:
138
+ r: the reference map.
139
+ cs: a list of content.
140
+
141
+ Returns:
142
+ A string, including a final newline.
143
+
144
+ Usually this is the reconstructed content of a Markdown file. Most
145
+ editors have a convention to end a file with a newline, but this
146
+ newline is usually stripped when we read a file.
147
+
148
+ Context:
149
+ In Python `"foo".splitlines()` gives the same as `"foo\n".splitlines()`,
150
+ with the exception of `"\n".splitlines()` giving `['']`, while
151
+ `"".splitlines()` returns `[]`.
152
+
153
+ As an alternative, we could keep line endings by splitting with `keepends=True`,
154
+ and joining with `"".join(...)`.
155
+ """
156
+ text = "\n".join(content_to_text(r, c) for c in cs)
157
+ if text[-1] != "\n":
158
+ return text + "\n"
159
+ else:
160
+ return text
@@ -1,11 +1,12 @@
1
1
  from dataclasses import dataclass
2
2
  from typing import Any, Callable
3
+ from pathlib import Path
3
4
 
4
5
  from ..text_location import TextLocation
5
6
 
6
7
 
7
8
  class UserError(Exception):
8
- def __str__(self):
9
+ def __str__(self) -> str:
9
10
  return "Unknown user error."
10
11
 
11
12
 
@@ -25,12 +26,20 @@ class HelpfulUserError(UserError):
25
26
  Make sure to also execute e.func() in your error handling."""
26
27
 
27
28
  msg: str
28
- func: Callable = lambda: None
29
+ func: Callable[[], Any] = lambda: None
29
30
 
30
31
  def __str__(self):
31
32
  return f"error: {self.msg}"
32
33
 
33
34
 
35
+ @dataclass
36
+ class FileError(UserError):
37
+ filename: Path
38
+
39
+ def __str__(self):
40
+ return f"file not found `{self.filename}`"
41
+
42
+
34
43
  @dataclass
35
44
  class IndentationError(UserError):
36
45
  location: TextLocation