entangled-cli 2.0.0b2__tar.gz → 2.0.0b5__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 (36) hide show
  1. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/PKG-INFO +48 -17
  2. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/README.md +46 -14
  3. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/commands/__init__.py +2 -1
  4. entangled_cli-2.0.0b5/entangled/commands/status.py +60 -0
  5. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/commands/stitch.py +1 -1
  6. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/commands/tangle.py +3 -1
  7. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/commands/watch.py +7 -6
  8. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/config/__init__.py +35 -7
  9. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/config/language.py +3 -2
  10. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/construct.py +3 -0
  11. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/document.py +6 -0
  12. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/filedb.py +12 -6
  13. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/hooks/__init__.py +9 -5
  14. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/hooks/base.py +12 -3
  15. entangled_cli-2.0.0b5/entangled/hooks/build.py +104 -0
  16. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/main.py +2 -2
  17. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/markdown_reader.py +12 -8
  18. entangled_cli-2.0.0b5/entangled/status.py +25 -0
  19. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/transaction.py +6 -0
  20. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/utility.py +0 -6
  21. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/pyproject.toml +2 -5
  22. entangled_cli-2.0.0b2/entangled/hooks/build.py +0 -73
  23. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/LICENSE +0 -0
  24. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/__init__.py +0 -0
  25. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/code_reader.py +0 -0
  26. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/commands/init.py +0 -0
  27. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/commands/sync.py +0 -0
  28. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/config/version.py +0 -0
  29. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/errors/__init__.py +0 -0
  30. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/errors/internal.py +0 -0
  31. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/errors/user.py +0 -0
  32. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/parsing.py +0 -0
  33. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/properties.py +0 -0
  34. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/py.typed +0 -0
  35. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/tangle.py +0 -0
  36. {entangled_cli-2.0.0b2 → entangled_cli-2.0.0b5}/entangled/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: entangled-cli
3
- Version: 2.0.0b2
3
+ Version: 2.0.0b5
4
4
  Summary: Literate Programming toolbox
5
5
  Home-page: https://entangled.github.io/
6
6
  License: Apache 2
@@ -10,11 +10,10 @@ Requires-Python: >=3.11,<4.0
10
10
  Classifier: License :: Other/Proprietary License
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.11
13
- Provides-Extra: rich
14
13
  Requires-Dist: argh (>=0.28.1,<0.29.0)
15
14
  Requires-Dist: filelock (>=3.12.0,<4.0.0)
16
15
  Requires-Dist: mawk (>=0.1.4,<0.2.0)
17
- Requires-Dist: rich (>=13.3.5,<14.0.0) ; extra == "rich"
16
+ Requires-Dist: rich (>=13.3.5,<14.0.0)
18
17
  Requires-Dist: watchdog (>=3.0.0,<4.0.0)
19
18
  Project-URL: Repository, https://github.com/entangled/entangled.py
20
19
  Description-Content-Type: text/markdown
@@ -148,6 +147,11 @@ And so on...
148
147
  ~~~
149
148
 
150
149
  ### Using the `build` hook
150
+
151
+ :::warning
152
+ The `build` hook has changed from version 2.0.0b3 to 2.0.0b4; Previously you would have to enter Makefile code yourself. Now, `target` and `deps` are specified as attributes to the codeblock, and the Makefile is generated for you. This means less features, but also a simpler interface that may be backed by other build systems than Make.
153
+ :::
154
+
151
155
  Entangled has a system of *hooks*, of which there exists currently only one: `build`. You can enable this hook in `entangled.toml`:
152
156
 
153
157
  ```toml
@@ -156,30 +160,34 @@ watch_list = ["docs/**/*.md"]
156
160
  hooks = ["build"]
157
161
  ```
158
162
 
159
- Then in your Markdown, you may enter Make code tagged with the `#build` id.
163
+ Then in your Markdown, you may enter code tagged with the `.build` tag.
160
164
 
161
165
  ~~~markdown
162
- ``` {.make #build target=docs/fig/plot.svg}
163
- docs/fig/plot.svg: src/plot.gnuplot
164
- > gnuplot $< > $@
166
+ ``` {.python .build target=docs/fig/plot.svg}
167
+ from matplotlib import pyplot as plt
168
+ import numpy as np
169
+
170
+ x = np.linspace(-np.pi, np.pi, 100)
171
+ y = np.sin(x)
172
+ plt.plot(x, y)
173
+ plt.savefig("docs/fig/plot.svg")
165
174
  ```
166
175
  ~~~
167
176
 
168
- Here, `$<` is the Make variable for the first dependency, and `$@` is the Make variable for the target. So, running this through `make` will create `plot.svg` if it doesn't exist yet or if it is older than `plot.gnuplot`.
177
+ This code will be saved into a Python script in the `.entangled/build` directory, or if you specify the `file=` attribute some other location. Second, a `Makefile` is generated in `.entangled/build`, that can be invoked as,
169
178
 
170
- Entangled adds a preamble to `#build`,
171
-
172
- ```make
173
- .RECIPEPREFIX = >
174
- .PHONY = all
179
+ ```shell
180
+ make -f .entangled/build/Makefile
181
+ ```
175
182
 
176
- all: {targets}
183
+ You may configure how code from different languages is evaluated in `entangled.toml`. For example, to add Gnuplot support, and also make Julia code run through `DaemonMode.jl`, you may do the following:
177
184
 
178
- {rules}
185
+ ```toml
186
+ [hook.build.runners]
187
+ Gnuplot = "gnuplot {script}"
188
+ Julia = "julia --project=. --startup-file=no -e 'using DaemonMode; runargs()' {script}"
179
189
  ```
180
190
 
181
- changing the prefix from `TAB` to `>` (you really want this), and collecting all `target=...` attributes into the `all` target.
182
-
183
191
  Once you have the code in place to generate figures and markdown tables, you can use the syntax at your disposal to include those into your Markdown. In this example that would be
184
192
 
185
193
  ~~~markdown
@@ -188,6 +196,29 @@ Once you have the code in place to generate figures and markdown tables, you can
188
196
 
189
197
  In the case of tables or other rich content, Standard Markdown (or CommonMark) has no syntax for including other Markdown files, so you'll have to check with your own document generator how to do that. In MkDocs, you could use `mkdocs-macro-plugin`, Pandoc has `pandoc-include`, etc.
190
198
 
199
+ You can also specify intermediate data generation like so:
200
+
201
+ ~~~markdown
202
+ ``` {.python .build target="data/result.csv"}
203
+ import numpy as np
204
+ import pandas as pd
205
+
206
+ result = np.random.normal(0.0, 1.0, (100, 2))
207
+ df = pd.DataFrame(result, columns=["x", "y"])
208
+ df.to_csv("data/result.csv")
209
+ ```
210
+
211
+ ``` {.python .build target="fig/plot.svg" deps="data/result.csv"}
212
+ import pandas as pd
213
+
214
+ df = pd.read_csv("data/result.csv")
215
+ plot = df.plot()
216
+ plot.savefig("fig/plot.svg")
217
+ ```
218
+ ~~~
219
+
220
+ The snippet for generating the data is given as a dependency for that data; to generate the figure, both `result.csv` and the code snippet are dependencies.
221
+
191
222
  #### Ideas for other hooks
192
223
 
193
224
  > A: Did you hack it together?
@@ -127,6 +127,11 @@ And so on...
127
127
  ~~~
128
128
 
129
129
  ### Using the `build` hook
130
+
131
+ :::warning
132
+ The `build` hook has changed from version 2.0.0b3 to 2.0.0b4; Previously you would have to enter Makefile code yourself. Now, `target` and `deps` are specified as attributes to the codeblock, and the Makefile is generated for you. This means less features, but also a simpler interface that may be backed by other build systems than Make.
133
+ :::
134
+
130
135
  Entangled has a system of *hooks*, of which there exists currently only one: `build`. You can enable this hook in `entangled.toml`:
131
136
 
132
137
  ```toml
@@ -135,30 +140,34 @@ watch_list = ["docs/**/*.md"]
135
140
  hooks = ["build"]
136
141
  ```
137
142
 
138
- Then in your Markdown, you may enter Make code tagged with the `#build` id.
143
+ Then in your Markdown, you may enter code tagged with the `.build` tag.
139
144
 
140
145
  ~~~markdown
141
- ``` {.make #build target=docs/fig/plot.svg}
142
- docs/fig/plot.svg: src/plot.gnuplot
143
- > gnuplot $< > $@
146
+ ``` {.python .build target=docs/fig/plot.svg}
147
+ from matplotlib import pyplot as plt
148
+ import numpy as np
149
+
150
+ x = np.linspace(-np.pi, np.pi, 100)
151
+ y = np.sin(x)
152
+ plt.plot(x, y)
153
+ plt.savefig("docs/fig/plot.svg")
144
154
  ```
145
155
  ~~~
146
156
 
147
- Here, `$<` is the Make variable for the first dependency, and `$@` is the Make variable for the target. So, running this through `make` will create `plot.svg` if it doesn't exist yet or if it is older than `plot.gnuplot`.
157
+ This code will be saved into a Python script in the `.entangled/build` directory, or if you specify the `file=` attribute some other location. Second, a `Makefile` is generated in `.entangled/build`, that can be invoked as,
148
158
 
149
- Entangled adds a preamble to `#build`,
150
-
151
- ```make
152
- .RECIPEPREFIX = >
153
- .PHONY = all
159
+ ```shell
160
+ make -f .entangled/build/Makefile
161
+ ```
154
162
 
155
- all: {targets}
163
+ You may configure how code from different languages is evaluated in `entangled.toml`. For example, to add Gnuplot support, and also make Julia code run through `DaemonMode.jl`, you may do the following:
156
164
 
157
- {rules}
165
+ ```toml
166
+ [hook.build.runners]
167
+ Gnuplot = "gnuplot {script}"
168
+ Julia = "julia --project=. --startup-file=no -e 'using DaemonMode; runargs()' {script}"
158
169
  ```
159
170
 
160
- changing the prefix from `TAB` to `>` (you really want this), and collecting all `target=...` attributes into the `all` target.
161
-
162
171
  Once you have the code in place to generate figures and markdown tables, you can use the syntax at your disposal to include those into your Markdown. In this example that would be
163
172
 
164
173
  ~~~markdown
@@ -167,6 +176,29 @@ Once you have the code in place to generate figures and markdown tables, you can
167
176
 
168
177
  In the case of tables or other rich content, Standard Markdown (or CommonMark) has no syntax for including other Markdown files, so you'll have to check with your own document generator how to do that. In MkDocs, you could use `mkdocs-macro-plugin`, Pandoc has `pandoc-include`, etc.
169
178
 
179
+ You can also specify intermediate data generation like so:
180
+
181
+ ~~~markdown
182
+ ``` {.python .build target="data/result.csv"}
183
+ import numpy as np
184
+ import pandas as pd
185
+
186
+ result = np.random.normal(0.0, 1.0, (100, 2))
187
+ df = pd.DataFrame(result, columns=["x", "y"])
188
+ df.to_csv("data/result.csv")
189
+ ```
190
+
191
+ ``` {.python .build target="fig/plot.svg" deps="data/result.csv"}
192
+ import pandas as pd
193
+
194
+ df = pd.read_csv("data/result.csv")
195
+ plot = df.plot()
196
+ plot.savefig("fig/plot.svg")
197
+ ```
198
+ ~~~
199
+
200
+ The snippet for generating the data is given as a dependency for that data; to generate the figure, both `result.csv` and the code snippet are dependencies.
201
+
170
202
  #### Ideas for other hooks
171
203
 
172
204
  > A: Did you hack it together?
@@ -2,6 +2,7 @@ from .tangle import tangle
2
2
  from .stitch import stitch
3
3
  from .sync import sync
4
4
  from .watch import watch
5
+ from .status import status
5
6
 
6
7
 
7
- __all__ = ["tangle", "stitch", "sync", "watch"]
8
+ __all__ = ["tangle", "stitch", "sync", "watch", "status"]
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+ from ..status import find_watch_dirs, list_input_files, list_dependent_files
3
+ from ..config import config
4
+ from pathlib import Path
5
+
6
+ try:
7
+ from rich.console import Console, Group
8
+ from rich.columns import Columns
9
+ from rich.table import Table
10
+ from rich.panel import Panel
11
+ from rich.tree import Tree
12
+
13
+ def tree_from_files(files):
14
+ tree = Tree(label=".")
15
+ dirs = { Path("."): tree }
16
+ for f in sorted(files):
17
+ for p in reversed(f.parents):
18
+ if p not in dirs:
19
+ dirs[p] = dirs[p.parent].add(p.name, style="repr.path")
20
+ dirs[f.parent].add(f.name, style="repr.filename")
21
+ return tree
22
+
23
+
24
+ def files_panel(file_list: list[str], title: str) -> Panel:
25
+ tree = tree_from_files(file_list)
26
+ return Panel(tree, title=title, border_style="dark_cyan")
27
+
28
+
29
+ def rich_status():
30
+ cfg = config.config
31
+ config_table = Table()
32
+ config_table.add_column("name")
33
+ config_table.add_column("value")
34
+ config_table.add_row("Watch list", ", ".join(f"'{pat}'" for pat in cfg.watch_list))
35
+ config_table.add_row("Hooks enabled", ", ".join(cfg.hooks))
36
+
37
+ console = Console(color_system="auto")
38
+ group = Group(
39
+ Panel(config_table, title="config", border_style="dark_cyan"),
40
+ Columns([
41
+ files_panel([str(f) for f in list_input_files()], "input files"),
42
+ files_panel([str(f) for f in list_dependent_files()], "dependent files"),
43
+ ]),
44
+ )
45
+
46
+ console.print(group)
47
+
48
+
49
+ WITH_RICH = True
50
+ except ImportError:
51
+ WITH_RICH = False
52
+
53
+
54
+ def status():
55
+ if WITH_RICH:
56
+ rich_status()
57
+ else:
58
+ print(config)
59
+ print("---")
60
+ print("input files:", sorted(list_input_files()))
@@ -50,7 +50,7 @@ def stitch(*, force: bool = False, show: bool = False):
50
50
  with transaction(mode) as t:
51
51
  for path in t.db.managed:
52
52
  logging.debug("reading `%s`", path)
53
- t.db.update(path)
53
+ t.update(path)
54
54
  with open(path, "r") as f:
55
55
  CodeReader(str(path), refs).run(f.read())
56
56
 
@@ -44,17 +44,19 @@ def tangle(*, annotate: Optional[str] = None, force: bool = False, show: bool =
44
44
  with transaction(mode) as t:
45
45
  for path in input_file_list:
46
46
  logging.debug("reading `%s`", path)
47
- t.db.update(path)
47
+ t.update(path)
48
48
  with open(path, "r") as f:
49
49
  MarkdownReader(str(path), refs, hooks).run(f.read())
50
50
 
51
51
  for tgt in refs.targets:
52
52
  result, deps = tangle_ref(refs, tgt, annotation_method)
53
53
  t.write(Path(tgt), result, list(map(Path, deps)))
54
+
54
55
  t.clear_orphans()
55
56
 
56
57
  for h in hooks:
57
58
  h.post_tangle(refs)
58
59
 
60
+
59
61
  except UserError as e:
60
62
  logging.error(str(e))
@@ -8,25 +8,26 @@ from watchdog.events import FileSystemEventHandler, FileSystemEvent
8
8
 
9
9
  from .sync import sync
10
10
  from ..config import config
11
- from ..filedb import file_db
11
+ from ..status import find_watch_dirs
12
12
 
13
13
 
14
14
  class EventHandler(FileSystemEventHandler):
15
15
  def __init__(self):
16
- input_file_list = chain.from_iterable(map(Path(".").glob, config.watch_list))
17
- markdown_dirs = set(p.parent for p in input_file_list)
18
- with file_db(readonly=True) as db:
19
- code_dirs = set(p.parent for p in db.managed)
20
- self.watched = code_dirs.union(markdown_dirs)
16
+ self.update_watched()
17
+
18
+ def update_watched(self):
19
+ self.watched = find_watch_dirs()
21
20
 
22
21
  def on_any_event(self, event: FileSystemEvent):
23
22
  if event.event_type == "opened":
24
23
  return
24
+ config.read()
25
25
  path = Path(event.src_path)
26
26
  if path.is_relative_to(Path("./.entangled")):
27
27
  return
28
28
  if any(path.is_relative_to(p) for p in self.watched):
29
29
  sync()
30
+ self.update_watched()
30
31
 
31
32
 
32
33
  def _watch(_stop_event: Optional[Event] = None):
@@ -4,7 +4,7 @@ defaults and config loaded from `entangled.toml` in the work directory.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from typing import Optional, ClassVar, TypeVar
7
+ from typing import Any, Optional, ClassVar, TypeVar
8
8
  from enum import Enum
9
9
  from dataclasses import dataclass, field
10
10
  from copy import copy
@@ -67,6 +67,7 @@ class Config(threading.local):
67
67
  annotation: AnnotationMethod = AnnotationMethod.STANDARD
68
68
  use_line_directives: bool = False
69
69
  hooks: list[str] = field(default_factory=list)
70
+ hook: dict = field(default_factory=dict)
70
71
 
71
72
  def __post_init__(self):
72
73
  self.languages = languages + self.languages
@@ -82,16 +83,42 @@ class Config(threading.local):
82
83
  default = Config(Version.from_string("2.0"))
83
84
 
84
85
 
85
- def read_config():
86
- if not Path("./entangled.toml").exists():
87
- return default
86
+ def read_config_from_toml(path: Path, section: Optional[str] = None) -> Optional[Config]:
87
+ """Read a config from given `path` in given `section`. The path should refer to
88
+ a TOML file that should decode to a `Config` object. If `section` is given, only
89
+ that section is decoded to a `Config` object. The `section` string may contain
90
+ periods to indicate deeper nesting.
91
+
92
+ Example:
93
+
94
+ ```python
95
+ read_config_from_toml(Path("./pyproject.toml"), "tool.entangled")
96
+ ```
97
+ """
98
+ if not path.exists():
99
+ return None
88
100
  try:
89
- with open(Path("./entangled.toml"), "rb") as f:
101
+ with open(path, "rb") as f:
90
102
  json = tomllib.load(f)
91
- return construct(Config, json)
103
+ if section is not None:
104
+ for s in section.split("."):
105
+ json = json[s]
106
+ return construct(Config, json)
92
107
  except ValueError as e:
93
108
  logging.error("Could not read config: %s", e)
94
- return default
109
+ return None
110
+ except KeyError as e:
111
+ logging.debug("%s", e)
112
+ logging.debug("The config file %s should contain a section %s", path, section)
113
+ return None
114
+
115
+
116
+ def read_config():
117
+ if Path("./entangled.toml").exists():
118
+ return read_config_from_toml(Path("./entangled.toml")) or default
119
+ if Path("./pyproject.toml").exists():
120
+ return read_config_from_toml(Path("./pyproject.toml"), "tool.entangled") or default
121
+ return default
95
122
 
96
123
 
97
124
  class ConfigWrapper:
@@ -120,3 +147,4 @@ class ConfigWrapper:
120
147
  config = ConfigWrapper(read_config())
121
148
  """The `config.config` variable is changed when the `config` module is loaded.
122
149
  Config is read from `entangled.toml` file."""
150
+
@@ -26,7 +26,7 @@ class Language:
26
26
 
27
27
 
28
28
  languages = [
29
- Language("Shell", ["sh", "bash", "fish", "zsh"], Comment("#")),
29
+ Language("Bash", ["sh", "bash"], Comment("#")),
30
30
  Language("C", ["c", "cpp", "c++"], Comment("/*", "*/")),
31
31
  Language("Python", ["python"], Comment("#")),
32
32
  Language("Rust", ["rust"], Comment("//")),
@@ -36,9 +36,10 @@ languages = [
36
36
  ),
37
37
  Language("Julia", ["julia"], Comment("#")),
38
38
  Language("Java", ["java"], Comment("//")),
39
- Language("PureScritp", ["pure", "purs", "purescript"], Comment("--")),
39
+ Language("PureScript", ["pure", "purs", "purescript"], Comment("--")),
40
40
  Language("CSS", ["css"], Comment("/*", "*/")),
41
41
  Language("Lua", ["lua"], Comment("--")),
42
42
  Language("Make", ["make", "makefile"], Comment("#")),
43
43
  Language("Gnuplot", ["gnuplot"], Comment("#")),
44
+ Language("TOML", ["toml"], Comment("#")),
44
45
  ]
@@ -28,6 +28,9 @@ def construct(annot, json):
28
28
  if isinstance(json, str) and isinstance(annot, Parser):
29
29
  result, _ = annot.read(json)
30
30
  return result
31
+ if annot is dict or isgeneric(annot) and typing.get_origin(annot) is dict:
32
+ assert isinstance(json, dict)
33
+ return json
31
34
  if isgeneric(annot) and typing.get_origin(annot) is list:
32
35
  assert isinstance(json, list)
33
36
  return [construct(typing.get_args(annot)[0], item) for item in json]
@@ -51,6 +51,12 @@ class CodeBlock:
51
51
 
52
52
  @dataclass
53
53
  class ReferenceMap:
54
+ """
55
+ Members:
56
+ `map`: maps references to actual code block content
57
+ `index`: maps names to list of references
58
+ `targets`: lists filenames; a target should be in `index`
59
+ """
54
60
  map: dict[ReferenceId, CodeBlock] = field(default_factory=dict)
55
61
  index: defaultdict[str, list[ReferenceId]] = field(
56
62
  default_factory=lambda: defaultdict(list)
@@ -16,6 +16,14 @@ from .version import __version__
16
16
  from .utility import normal_relative, ensure_parent
17
17
 
18
18
 
19
+ def hexdigest(s: str) -> str:
20
+ """Creates a MD5 hash digest from a string. Before hashing, the string has
21
+ linefeed `\\r` characters and trailing newlines removed, and the string
22
+ is encoded as UTF-8."""
23
+ content = s.replace("\r", "").rstrip("\n").encode()
24
+ return hashlib.sha256(content).hexdigest()
25
+
26
+
19
27
  @dataclass
20
28
  class FileStat:
21
29
  path: Path
@@ -26,10 +34,10 @@ class FileStat:
26
34
  @staticmethod
27
35
  def from_path(path: Path, deps: Optional[list[Path]]):
28
36
  stat = os.stat(path)
29
- with open(path, "rb") as f:
30
- hash = hashlib.sha256(f.read())
37
+ with open(path, "r") as f:
38
+ digest = hexdigest(f.read())
31
39
  return FileStat(
32
- path, deps, datetime.fromtimestamp(stat.st_mtime), hash.hexdigest()
40
+ path, deps, datetime.fromtimestamp(stat.st_mtime), digest
33
41
  )
34
42
 
35
43
  def __lt__(self, other: FileStat) -> bool:
@@ -136,9 +144,7 @@ class FileDB:
136
144
  return self._files.keys()
137
145
 
138
146
  def check(self, path: Path, content: str) -> bool:
139
- return (
140
- hashlib.sha256(content.encode()).hexdigest() == self._files[path].hexdigest
141
- )
147
+ return hexdigest(content) == self._files[path].hexdigest
142
148
 
143
149
  @staticmethod
144
150
  def initialize() -> FileDB:
@@ -1,10 +1,12 @@
1
1
  import logging
2
- from .base import HookBase
3
- from .build import BuildHook, PrerequisitesFailed
2
+ from typing import Any
3
+ from .base import HookBase, PrerequisitesFailed
4
+ from . import build
4
5
  from ..config import config
6
+ from ..construct import construct
5
7
 
6
8
 
7
- hooks: dict[str, type[HookBase]] = {"build": BuildHook}
9
+ hooks: dict[str, type[HookBase]] = {"build": build.Hook}
8
10
 
9
11
 
10
12
  def get_hooks() -> list[HookBase]:
@@ -12,8 +14,10 @@ def get_hooks() -> list[HookBase]:
12
14
  for h in config.hooks:
13
15
  if h in hooks:
14
16
  try:
15
- hooks[h].check_prerequisites()
16
- active_hooks.append(hooks[h]())
17
+ hook_cfg = construct(hooks[h].Config, config.hook.get(h, {}))
18
+ hook_instance = hooks[h](hook_cfg)
19
+ hook_instance.check_prerequisites()
20
+ active_hooks.append(hook_instance)
17
21
  except PrerequisitesFailed as e:
18
22
  logging.error("hook `%s`: %s", h, str(e))
19
23
  else:
@@ -1,4 +1,5 @@
1
1
  from dataclasses import dataclass
2
+ from typing import Any
2
3
 
3
4
  from ..properties import Property
4
5
  from ..document import ReferenceMap, ReferenceId, CodeBlock
@@ -13,16 +14,24 @@ class PrerequisitesFailed(Exception):
13
14
 
14
15
 
15
16
  class HookBase:
16
- @staticmethod
17
- def check_prerequisites():
17
+ class Config:
18
+ pass
19
+
20
+ def __init__(self, config: Config):
21
+ pass
22
+
23
+ def check_prerequisites(self):
18
24
  """When prerequisites aren't met, raise PrerequisitesFailed."""
19
25
  return
20
26
 
21
27
  def condition(self, props: list[Property]):
22
28
  raise NotImplementedError()
23
29
 
24
- def on_read(self, refs: ReferenceId, code: CodeBlock):
30
+ def on_read(self, refs: ReferenceMap, ref: ReferenceId, code: CodeBlock):
25
31
  raise NotImplementedError()
26
32
 
33
+ def pre_tangle(self, refs: ReferenceMap):
34
+ pass
35
+
27
36
  def post_tangle(self, refs: ReferenceMap):
28
37
  pass
@@ -0,0 +1,104 @@
1
+ """
2
+ The `build` hook collects code blocks that are tagged with the `#build`
3
+ identifier and have a `target=` attribute defined. These code blocks are put
4
+ together into a temporary Makefile that is run from the current working
5
+ directory.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from subprocess import run, SubprocessError, DEVNULL
12
+
13
+ from entangled.config.language import Language
14
+
15
+ from ..properties import Property, get_attribute, get_classes
16
+ from ..document import ReferenceId, ReferenceMap, CodeBlock
17
+
18
+ from .base import HookBase, PrerequisitesFailed
19
+
20
+ """
21
+ The Makefile is generated with `preamble` as a format string. The
22
+ `.RECIPEPREFIX` is set to `>` , and `target` attributes are collected into an
23
+ `all` target.
24
+ """
25
+ preamble = """
26
+ # This Makefile is generated by Entangled. Modifications will be overwritten.
27
+
28
+ .RECIPEPREFIX = >
29
+ .PHONY = all
30
+
31
+ all: {targets}
32
+
33
+ {rules}
34
+ """
35
+
36
+ EXEC_CMDS = {
37
+ "Python": "python {script}",
38
+ "Bash": "bash {script}",
39
+ "Julia": "julia -O0 --project=. {script}",
40
+ }
41
+
42
+
43
+ class Hook(HookBase):
44
+ @dataclass
45
+ class Config(HookBase.Config):
46
+ runners: dict[str, str] = field(default_factory=dict)
47
+
48
+ def __post_init__(self):
49
+ for k, v in EXEC_CMDS.items():
50
+ if k not in self.runners:
51
+ self.runners[k] = v
52
+
53
+ @dataclass
54
+ class Recipe:
55
+ target: str
56
+ dependencies: list[str]
57
+ language: Language
58
+ scriptfile: str
59
+
60
+ def to_makefile(self, config: Hook.Config):
61
+ dep_str = " ".join(self.dependencies)
62
+ exec_cmd = config.runners[self.language.name].format(script=self.scriptfile)
63
+ return f"{self.target}: {self.scriptfile} {dep_str}\n" \
64
+ f"> {exec_cmd}"
65
+
66
+ def __init__(self, config: Hook.Config):
67
+ self.recipes: list[Hook.Recipe] = []
68
+ self.config = config
69
+
70
+ def check_prerequisites(self):
71
+ """Check that `make` is installed."""
72
+ try:
73
+ run(["make", "--version"], stdout=DEVNULL)
74
+ except (SubprocessError, FileNotFoundError):
75
+ raise PrerequisitesFailed("GNU Make needs to be installed")
76
+
77
+ def condition(self, props: list[Property]):
78
+ """Condition by which a CodeBlock is processed: should have `.build` class
79
+ and `target=` attribute."""
80
+ return "build" in get_classes(props) \
81
+ and get_attribute(props, "target") is not None
82
+
83
+ def on_read(self, refs: ReferenceMap, ref: ReferenceId, cb: CodeBlock):
84
+ """Add a CodeBlock's target attribute to the list of targets."""
85
+ target = get_attribute(cb.properties, "target")
86
+ if target is None:
87
+ return
88
+
89
+ script_file_name = get_attribute(cb.properties, "file")
90
+ if script_file_name is None:
91
+ script_file_name = f".entangled/build/{ref.name}".replace(":", "_")
92
+ refs.index[script_file_name].append(ref)
93
+ refs.targets.add(script_file_name)
94
+
95
+ deps = (get_attribute(cb.properties, "deps") or "").split()
96
+ self.recipes.append(Hook.Recipe(target, deps, cb.language, script_file_name))
97
+
98
+ def post_tangle(self, _: ReferenceMap):
99
+ """After all code is tangled: retrieve the build scripts and run it."""
100
+ targets = " ".join([r.target for r in self.recipes])
101
+ makefile = preamble.format(targets=targets, rules="\n\n".join(r.to_makefile(self.config) for r in self.recipes))
102
+ Path(".entangled/build").mkdir(exist_ok=True, parents=True)
103
+ Path(".entangled/build/Makefile").write_text(makefile)
104
+
@@ -12,7 +12,7 @@ except ImportError:
12
12
  WITH_RICH = False
13
13
 
14
14
 
15
- from .commands import tangle, stitch, sync, watch
15
+ from .commands import tangle, stitch, sync, watch, status
16
16
  from .errors.internal import bug_contact
17
17
  from .errors.user import UserError
18
18
  from .version import __version__
@@ -57,7 +57,7 @@ def cli():
57
57
  parser.add_argument(
58
58
  "-v", "--version", action="store_true", help="show version number"
59
59
  )
60
- argh.add_commands(parser, [tangle, stitch, sync, watch])
60
+ argh.add_commands(parser, [tangle, stitch, sync, watch, status])
61
61
  args = parser.parse_args()
62
62
 
63
63
  if args.version:
@@ -87,10 +87,17 @@ class MarkdownReader(mawk.RuleSet):
87
87
  language_class = first(get_classes(self.current_codeblock_properties))
88
88
  block_id = get_id(self.current_codeblock_properties)
89
89
  target_file = get_attribute(self.current_codeblock_properties, "file")
90
- ref_name = block_id or target_file
91
90
  language = config.get_language(language_class) if language_class else None
92
91
 
93
- if ref_name is None or language is None:
92
+ content = "\n".join(
93
+ line.removeprefix(self.current_codeblock_indent)
94
+ for line in self.current_content)
95
+
96
+ ref_name = block_id or target_file
97
+ if ref_name is None:
98
+ ref_name = f"unnamed-{self.location}"
99
+
100
+ if language is None:
94
101
  self.flush_plain_text()
95
102
  else:
96
103
  ref = self.reference_map.new_id(
@@ -100,13 +107,10 @@ class MarkdownReader(mawk.RuleSet):
100
107
  language,
101
108
  self.current_codeblock_properties,
102
109
  self.current_codeblock_indent,
103
- "\n".join(
104
- line.removeprefix(self.current_codeblock_indent)
105
- for line in self.current_content
106
- ),
110
+ content,
107
111
  self.current_codeblock_location,
108
112
  )
109
- logging.debug(repr(code))
113
+ # logging.debug(repr(code))
110
114
  self.reference_map[ref] = code
111
115
  if target_file is not None:
112
116
  self.reference_map.targets.add(target_file)
@@ -115,7 +119,7 @@ class MarkdownReader(mawk.RuleSet):
115
119
 
116
120
  for h in self.hooks:
117
121
  if h.condition(self.current_codeblock_properties):
118
- h.on_read(ref, code)
122
+ h.on_read(self.reference_map, ref, code)
119
123
 
120
124
  self.current_content.append(m[0])
121
125
  self.inside_codeblock = False
@@ -0,0 +1,25 @@
1
+ from .config import config
2
+ from .filedb import file_db
3
+
4
+ from itertools import chain
5
+ from pathlib import Path
6
+
7
+
8
+ def find_watch_dirs():
9
+ """List all directories that contain files that need watching."""
10
+ input_file_list = list_input_files()
11
+ markdown_dirs = set(p.parent for p in input_file_list)
12
+ with file_db(readonly=True) as db:
13
+ code_dirs = set(p.parent for p in db.managed)
14
+ return code_dirs.union(markdown_dirs)
15
+
16
+
17
+ def list_input_files():
18
+ """List all input files."""
19
+ return chain.from_iterable(map(Path(".").glob, config.watch_list))
20
+
21
+
22
+ def list_dependent_files():
23
+ with file_db(readonly=True) as db:
24
+ result = list(db.managed)
25
+ return result
@@ -105,9 +105,13 @@ class Delete(Action):
105
105
  @dataclass
106
106
  class Transaction:
107
107
  db: FileDB
108
+ updates: list[Path] = field(default_factory=list)
108
109
  actions: list[Action] = field(default_factory=list)
109
110
  passed: set[Path] = field(default_factory=set)
110
111
 
112
+ def update(self, path: Path):
113
+ self.updates.append(path)
114
+
111
115
  def write(self, path: Path, content: str, sources: list[Path]):
112
116
  if path in self.passed:
113
117
  raise InternalError("Path is being written to twice", [path])
@@ -147,6 +151,8 @@ class Transaction:
147
151
  def run(self):
148
152
  for a in self.actions:
149
153
  a.run(self.db)
154
+ for f in self.updates:
155
+ self.db.update(f)
150
156
 
151
157
 
152
158
  class TransactionMode(Enum):
@@ -11,12 +11,6 @@ import types
11
11
  T = TypeVar("T")
12
12
 
13
13
 
14
- @contextmanager
15
- def pushd(wd: Path):
16
- olddir = os.getcwd()
17
- os.chdir(wd)
18
- yield wd
19
- os.chdir(olddir)
20
14
 
21
15
 
22
16
  def first(it: Iterable[T]) -> Optional[T]:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "entangled-cli"
3
- version = "2.0.0-beta-2"
3
+ version = "2.0.0-beta-5"
4
4
  description = "Literate Programming toolbox"
5
5
  repository = "https://github.com/entangled/entangled.py"
6
6
  homepage = "https://entangled.github.io/"
@@ -17,7 +17,7 @@ mawk = "^0.1.4" # use mawk as line parsing engine
17
17
  watchdog = "^3.0.0" # file watching
18
18
  filelock = "^3.12.0" # file lock for json db
19
19
  argh = "^0.28.1"
20
- rich = { version = "^13.3.5", optional = true }
20
+ rich = "^13.3.5"
21
21
 
22
22
  [tool.poetry.group.dev.dependencies]
23
23
  pytest = "^7.3.1"
@@ -29,9 +29,6 @@ mkdocs-material = "^9.1.13"
29
29
  mkdocstrings = {extras = ["python"], version = "^0.21.2"}
30
30
  twine = "^4.0.2"
31
31
 
32
- [tool.poetry.extras]
33
- rich = ["rich"]
34
-
35
32
  [build-system]
36
33
  requires = ["poetry-core"]
37
34
  build-backend = "poetry.core.masonry.api"
@@ -1,73 +0,0 @@
1
- """
2
- The `build` hook collects code blocks that are tagged with the `#build`
3
- identifier and have a `target=` attribute defined. These code blocks are put
4
- together into a temporary Makefile that is run from the current working
5
- directory.
6
- """
7
-
8
- from tempfile import TemporaryDirectory
9
- from pathlib import Path
10
- from subprocess import run, SubprocessError, DEVNULL
11
-
12
- import logging
13
- import os
14
-
15
- from ..properties import Property, get_id, get_attribute
16
- from ..tangle import tangle_ref
17
- from ..document import ReferenceMap, CodeBlock
18
-
19
- from .base import HookBase, PrerequisitesFailed
20
-
21
- """
22
- The Makefile is generated with `preamble` as a format string. The
23
- `.RECIPEPREFIX` is set to `>` , and `target` attributes are collected into an
24
- `all` target.
25
- """
26
- preamble = """
27
- .RECIPEPREFIX = >
28
- .PHONY = all
29
-
30
- all: {targets}
31
-
32
- {rules}
33
- """
34
-
35
-
36
- class BuildHook(HookBase):
37
- def __init__(self):
38
- self.targets = []
39
-
40
- @staticmethod
41
- def check_prerequisites():
42
- """Check that `make` is installed."""
43
- try:
44
- run(["make", "--version"], stdout=DEVNULL)
45
- except (SubprocessError, FileNotFoundError):
46
- raise PrerequisitesFailed("GNU Make needs to be installed")
47
-
48
- def condition(self, props: list[Property]):
49
- """Condition by which a CodeBlock is processed: should have `#build` id
50
- and `target=` attribute."""
51
- return get_id(props) == "build" and get_attribute(props, "target") is not None
52
-
53
- def on_read(self, _, code: CodeBlock):
54
- """Add a CodeBlock's target attribute to the list of targets."""
55
- target = get_attribute(code.properties, "target")
56
- if target is None:
57
- return
58
- self.targets.append(target)
59
-
60
- def post_tangle(self, refs: ReferenceMap):
61
- """After all code is tangled: retrieve the `#build` script and run it."""
62
- if "CI" in os.environ:
63
- logging.info("CI run detected, skipping `build` hook.")
64
- return
65
-
66
- logging.info("Building artifacts with `make`.")
67
- with TemporaryDirectory() as _pwd:
68
- pwd = Path(_pwd)
69
- script = preamble.format(
70
- targets=" ".join(self.targets), rules=tangle_ref(refs, "build")[0]
71
- )
72
- (pwd / "Makefile").write_text(script)
73
- run(["make", "-f", str(pwd / "Makefile")], stdout=DEVNULL)
File without changes