entangled-cli 2.5.0__tar.gz → 2.6.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 (78) hide show
  1. entangled_cli-2.5.0/README.md → entangled_cli-2.6.0/PKG-INFO +43 -0
  2. entangled_cli-2.5.0/PKG-INFO → entangled_cli-2.6.0/README.md +16 -25
  3. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/__init__.py +2 -1
  4. entangled_cli-2.6.0/entangled/commands/weave.py +50 -0
  5. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/errors/user.py +12 -0
  6. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/repl.py +13 -3
  7. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/interface/document.py +25 -5
  8. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/io/stat.py +7 -4
  9. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/logging.py +1 -1
  10. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/tangle.py +3 -1
  11. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/readers/markdown.py +5 -1
  12. entangled_cli-2.6.0/entangled/weave/__init__.py +9 -0
  13. entangled_cli-2.6.0/entangled/weave/base.py +66 -0
  14. entangled_cli-2.6.0/entangled/weave/filters.py +235 -0
  15. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/pyproject.toml +8 -2
  16. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/.gitignore +0 -0
  17. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/LICENSE +0 -0
  18. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/__init__.py +0 -0
  19. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/brei.py +0 -0
  20. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/main.py +0 -0
  21. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/new.py +0 -0
  22. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/reset.py +0 -0
  23. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/serve.py +0 -0
  24. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/status.py +0 -0
  25. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/stitch.py +0 -0
  26. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/sync.py +0 -0
  27. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/tangle.py +0 -0
  28. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/commands/watch.py +0 -0
  29. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/__init__.py +0 -0
  30. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/annotation_method.py +0 -0
  31. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/config_data.py +0 -0
  32. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/config_update.py +0 -0
  33. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/document_style.py +0 -0
  34. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/language.py +0 -0
  35. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/markers.py +0 -0
  36. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/namespace_default.py +0 -0
  37. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/templates.py +0 -0
  38. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/config/version.py +0 -0
  39. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/errors/__init__.py +0 -0
  40. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/errors/internal.py +0 -0
  41. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/__init__.py +0 -0
  42. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/base.py +0 -0
  43. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/build.py +0 -0
  44. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/quarto_attributes.py +0 -0
  45. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/shebang.py +0 -0
  46. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/spdx_license.py +0 -0
  47. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/hooks/task.py +0 -0
  48. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/interface/__init__.py +0 -0
  49. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/interface/context.py +0 -0
  50. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/io/__init__.py +0 -0
  51. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/io/filedb.py +0 -0
  52. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/io/transaction.py +0 -0
  53. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/io/virtual.py +0 -0
  54. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/iterators/__init__.py +0 -0
  55. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/iterators/lines.py +0 -0
  56. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/iterators/peekable.py +0 -0
  57. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/iterators/run_generator.py +0 -0
  58. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/main.py +0 -0
  59. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/__init__.py +0 -0
  60. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/code_block.py +0 -0
  61. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/content.py +0 -0
  62. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/properties.py +0 -0
  63. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/reference_id.py +0 -0
  64. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/reference_map.py +0 -0
  65. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/model/reference_name.py +0 -0
  66. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/parsing.py +0 -0
  67. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/py.typed +0 -0
  68. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/readers/__init__.py +0 -0
  69. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/readers/code.py +0 -0
  70. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/readers/delimiters.py +0 -0
  71. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/readers/types.py +0 -0
  72. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/readers/yaml_header.py +0 -0
  73. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/server/__init__.py +0 -0
  74. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/server/server.py +0 -0
  75. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/status.py +0 -0
  76. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/text_location.py +0 -0
  77. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/utility.py +0 -0
  78. {entangled_cli-2.5.0 → entangled_cli-2.6.0}/entangled/version.py +0 -0
@@ -1,3 +1,30 @@
1
+ Metadata-Version: 2.5
2
+ Name: entangled-cli
3
+ Version: 2.6.0
4
+ Summary: Literate Programming toolbox
5
+ Project-URL: Homepage, https://entangled.github.io/
6
+ Project-URL: Repository, https://github.com/entangled/entangled.py
7
+ Author-email: Johan Hidding <j.hidding@esciencecenter.nl>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Requires-Python: <4,>=3.13
11
+ Requires-Dist: brei<0.3,>=0.2.3
12
+ Requires-Dist: click>=8.3.0
13
+ Requires-Dist: copier<10,>=9
14
+ Requires-Dist: filelock<5,>=3.12.0
15
+ Requires-Dist: msgspec>=0.20.0
16
+ Requires-Dist: pyyaml<7,>=6.0.1
17
+ Requires-Dist: repl-session>=0.3.0
18
+ Requires-Dist: rich-argparse<2,>=1.4.0
19
+ Requires-Dist: rich-click>=1.9.4
20
+ Requires-Dist: rich<16,>=13
21
+ Requires-Dist: tomlkit<0.16,>=0.12.1
22
+ Requires-Dist: typeguard>=4.4.4
23
+ Requires-Dist: watchfiles>=1.3
24
+ Provides-Extra: jupyter
25
+ Requires-Dist: repl-session[jupyter]>=0.3.0; extra == 'jupyter'
26
+ Description-Content-Type: text/markdown
27
+
1
28
  # Entangled
2
29
 
3
30
  [![Upload Python Package](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml/badge.svg)](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml)
@@ -440,6 +467,22 @@ You can use `${variable}` syntax inside Brei tasks just as you would in a stand-
440
467
 
441
468
  Entangled has been used successfully with the following document generators. Note that some of these examples were built using older versions of Entangled, but they should work just the same.
442
469
 
470
+ ### Weaving
471
+
472
+ The `entangled weave` command transforms a Markdown document into Markdown that renders well in a documentation tool, by applying one or more filters to the code blocks:
473
+
474
+ ```shell
475
+ entangled weave --list-filters # show available filters
476
+ entangled weave docs/index.md # apply the `label` filter, print to stdout
477
+ entangled weave -f title docs/index.md -o out/index.md # MkDocs/pymdownx style titles
478
+ ```
479
+
480
+ - `label` wraps each named code block in a `<div class="entangled-code-block">` with a label showing its name or target file. This renders in most Markdown previews (GitHub, Pandoc, Python-Markdown with `md_in_html`).
481
+ - `title` adds a `title` attribute to the code block header, as understood by `pymdownx.superfences`.
482
+ - `repl` inserts the results of a REPL session (see the `repl` hook) after each code block with the `repl` class. The results are read from the session file with its extension replaced by `.out.json`, e.g. `repl-session < session.json > session.out.json`. Text output becomes a `{.text .output}` code block; with the Jupyter backend, rich output (Markdown, HTML, SVG, PNG/JPEG) is included as well, with images embedded as data URIs. Set a `mime-type` attribute on the code block to choose a representation.
483
+
484
+ Filters can be combined by repeating `-f`; they are applied in the given order. The same transformation is available from Python through `entangled.weave.transform_markdown`, so that documentation tools only need a thin adapter.
485
+
443
486
  ### Pandoc
444
487
 
445
488
  [Pandoc](https://pandoc.org/) is a very versatile tool for converting documents in any format. It specifically has very wide support for different forms of Markdown syntax out in the wild, including a filter system that lets you extend the workings of Pandoc. Those filters can be written in any language through an API, for instance Python filters can be written using `panflute`, but there is also native support for Lua.
@@ -1,28 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: entangled-cli
3
- Version: 2.5.0
4
- Summary: Literate Programming toolbox
5
- Project-URL: Homepage, https://entangled.github.io/
6
- Project-URL: Repository, https://github.com/entangled/entangled.py
7
- Author-email: Johan Hidding <j.hidding@esciencecenter.nl>
8
- License-Expression: Apache-2.0
9
- License-File: LICENSE
10
- Requires-Python: <4,>=3.13
11
- Requires-Dist: brei<0.3,>=0.2.3
12
- Requires-Dist: click>=8.3.0
13
- Requires-Dist: copier<10,>=9
14
- Requires-Dist: filelock<5,>=3.12.0
15
- Requires-Dist: msgspec>=0.20.0
16
- Requires-Dist: pyyaml<7,>=6.0.1
17
- Requires-Dist: repl-session>=0.2.0
18
- Requires-Dist: rich-argparse<2,>=1.4.0
19
- Requires-Dist: rich-click>=1.9.4
20
- Requires-Dist: rich<16,>=13
21
- Requires-Dist: tomlkit<0.16,>=0.12.1
22
- Requires-Dist: typeguard>=4.4.4
23
- Requires-Dist: watchfiles>=1.3
24
- Description-Content-Type: text/markdown
25
-
26
1
  # Entangled
27
2
 
28
3
  [![Upload Python Package](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml/badge.svg)](https://github.com/entangled/entangled.py/actions/workflows/deploy.yml)
@@ -465,6 +440,22 @@ You can use `${variable}` syntax inside Brei tasks just as you would in a stand-
465
440
 
466
441
  Entangled has been used successfully with the following document generators. Note that some of these examples were built using older versions of Entangled, but they should work just the same.
467
442
 
443
+ ### Weaving
444
+
445
+ The `entangled weave` command transforms a Markdown document into Markdown that renders well in a documentation tool, by applying one or more filters to the code blocks:
446
+
447
+ ```shell
448
+ entangled weave --list-filters # show available filters
449
+ entangled weave docs/index.md # apply the `label` filter, print to stdout
450
+ entangled weave -f title docs/index.md -o out/index.md # MkDocs/pymdownx style titles
451
+ ```
452
+
453
+ - `label` wraps each named code block in a `<div class="entangled-code-block">` with a label showing its name or target file. This renders in most Markdown previews (GitHub, Pandoc, Python-Markdown with `md_in_html`).
454
+ - `title` adds a `title` attribute to the code block header, as understood by `pymdownx.superfences`.
455
+ - `repl` inserts the results of a REPL session (see the `repl` hook) after each code block with the `repl` class. The results are read from the session file with its extension replaced by `.out.json`, e.g. `repl-session < session.json > session.out.json`. Text output becomes a `{.text .output}` code block; with the Jupyter backend, rich output (Markdown, HTML, SVG, PNG/JPEG) is included as well, with images embedded as data URIs. Set a `mime-type` attribute on the code block to choose a representation.
456
+
457
+ Filters can be combined by repeating `-f`; they are applied in the given order. The same transformation is available from Python through `entangled.weave.transform_markdown`, so that documentation tools only need a thin adapter.
458
+
468
459
  ### Pandoc
469
460
 
470
461
  [Pandoc](https://pandoc.org/) is a very versatile tool for converting documents in any format. It specifically has very wide support for different forms of Markdown syntax out in the wild, including a filter system that lets you extend the workings of Pandoc. Those filters can be written in any language through an API, for instance Python filters can be written using `panflute`, but there is also native support for Lua.
@@ -8,7 +8,8 @@ from .watch import watch
8
8
  from .status import status
9
9
  from .brei import brei
10
10
  from .serve import serve
11
+ from .weave import weave
11
12
 
12
13
  __all__ = [
13
- "main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei", "serve"
14
+ "main", "stitch", "tangle", "new", "reset", "sync", "watch", "status", "brei", "serve", "weave"
14
15
  ]
@@ -0,0 +1,50 @@
1
+ from pathlib import Path, PurePath
2
+ import sys
3
+
4
+ import rich_click as click
5
+
6
+ from .main import main
7
+
8
+ from ..config import read_config
9
+ from ..interface import Context
10
+ from ..weave import filters, filter_description, transform_markdown
11
+
12
+
13
+ def print_filters():
14
+ width = max(map(len, filters))
15
+ for name, f in filters.items():
16
+ print(f"{name:<{width}} {filter_description(f)}")
17
+
18
+
19
+ @main.command()
20
+ @click.argument("input", type=click.Path(dir_okay=False, allow_dash=True, path_type=Path),
21
+ default="-")
22
+ @click.option("-o", "--output", type=click.Path(dir_okay=False, allow_dash=True, path_type=Path),
23
+ default="-", help="output file, defaults to standard output")
24
+ @click.option("-f", "--filter", "filter_names", type=click.Choice(list(filters)), multiple=True,
25
+ help="filter to apply, can be repeated; defaults to `label`")
26
+ @click.option("--list-filters", is_flag=True, help="list available filters and exit")
27
+ def weave(*, input: Path, output: Path, filter_names: tuple[str, ...] = (),
28
+ list_filters: bool = False):
29
+ """Transform Markdown for rendering by documentation tools."""
30
+ if list_filters:
31
+ print_filters()
32
+ return
33
+
34
+ context = Context()
35
+ context |= read_config(context.fs)
36
+
37
+ if input == Path("-"):
38
+ text = sys.stdin.read()
39
+ filename = PurePath("-")
40
+ else:
41
+ text = input.read_text()
42
+ filename = input
43
+
44
+ selected = [filters[n] for n in (filter_names or ("label",))]
45
+ result = "".join(transform_markdown(context, text, *selected, filename=filename))
46
+
47
+ if output == Path("-"):
48
+ _ = sys.stdout.write(result)
49
+ else:
50
+ _ = output.write_text(result)
@@ -73,3 +73,15 @@ class CodeAttributeError(UserError):
73
73
  @override
74
74
  def __str__(self) -> str:
75
75
  return f"{self.origin}: Attribute error: {self.msg}"
76
+
77
+
78
+ @dataclass
79
+ class StitchConflict(UserError):
80
+ reference: str
81
+ files: tuple[Path, Path]
82
+
83
+ @override
84
+ def __str__(self) -> str:
85
+ a, b = self.files
86
+ return (f"code block `{self.reference}` was changed differently in "
87
+ f"`{a}` and `{b}`; resolve this by hand")
@@ -6,7 +6,7 @@ import msgspec
6
6
 
7
7
  from entangled.config.language import Language
8
8
  from .base import HookBase
9
- from repl_session import ReplConfig, ReplSession, ReplCommand
9
+ from repl_session import JupyterConfig, ReplConfig, ReplSession, ReplCommand
10
10
  from msgspec import Struct
11
11
  from pathlib import Path
12
12
 
@@ -18,6 +18,15 @@ from ..model.properties import Class, get_attribute, get_attribute_string, get_i
18
18
 
19
19
  log = logger()
20
20
 
21
+ type BackendConfig = ReplConfig | JupyterConfig
22
+
23
+
24
+ def backend_config(cfg: dict[str, object]) -> BackendConfig:
25
+ """Convert a REPL config, defaulting to the `repl` (pexpect) backend when
26
+ no `backend` is given."""
27
+ return msgspec.convert({"backend": "repl"} | cfg, type=BackendConfig)
28
+
29
+
21
30
  class Session(Struct):
22
31
  filename: Path
23
32
  language: str
@@ -35,7 +44,7 @@ def strip_comments(code: str, language: Language) -> str:
35
44
  @final
36
45
  class Hook(HookBase):
37
46
  class Config(HookBase.Config):
38
- config: dict[str, ReplConfig] = msgspec.field(default_factory=dict)
47
+ config: dict[str, dict[str, object]] = msgspec.field(default_factory=dict)
39
48
 
40
49
  @dataclass
41
50
  class State(HookBase.State):
@@ -44,7 +53,8 @@ class Hook(HookBase):
44
53
  def __init__(self, config: Config, state: State):
45
54
  super().__init__(config, state)
46
55
  log.debug(f"REPL hook config: {config}")
47
- self.config = config.config
56
+ self.config: dict[str, BackendConfig] = {
57
+ lang: backend_config(cfg) for lang, cfg in config.config.items()}
48
58
  self.sessions: dict[str, Session] = state.sessions
49
59
 
50
60
  @override
@@ -2,11 +2,12 @@ from dataclasses import dataclass, field
2
2
  from pathlib import PurePath, Path
3
3
 
4
4
  from ..config import Config, ConfigUpdate, get_input_files, read_config, AnnotationMethod
5
- from ..model import ReferenceMap, tangle_ref, Content, content_to_text
5
+ from ..model import ReferenceMap, ReferenceId, tangle_ref, Content, content_to_text
6
6
  from ..io import AbstractFileCache, FileCache, Transaction
7
7
  from ..readers import code
8
8
  from ..iterators import numbered_lines, run_generator
9
9
  from ..logging import logger
10
+ from ..errors.user import StitchConflict
10
11
 
11
12
  from .context import Context, markdown
12
13
 
@@ -65,18 +66,37 @@ class Document:
65
66
  t.update(path)
66
67
  return update
67
68
 
68
- def load_code(self, t: Transaction, path: Path):
69
+ def load_code(self, t: Transaction, path: Path,
70
+ loaded: dict[ReferenceId, Path] | None = None):
71
+ """Read code blocks back from a tangled file. If `loaded` is given, it
72
+ records which file each block was read from, so that a block that
73
+ was read before with different content raises a `StitchConflict`."""
69
74
  reader = code(numbered_lines(path, t.read(path)))
70
75
  for block in reader:
71
- self.reference_map[block.reference_id].source = block.content
76
+ ref = block.reference_id
77
+ if loaded is not None:
78
+ if ref in loaded and self.reference_map[ref].source != block.content:
79
+ raise StitchConflict(str(ref), (loaded[ref], path))
80
+ loaded[ref] = path
81
+ self.reference_map[ref].source = block.content
72
82
  t.update(path)
73
83
 
74
84
  def load_all_code(self, t: Transaction):
85
+ """Read code blocks back from all targets that changed since they were
86
+ tangled. Unchanged targets are skipped: they match the documentation,
87
+ and when they share a block with a changed target, reading them would
88
+ undo the change."""
75
89
  log.debug(f"Targets: {self.reference_map.targets()}")
90
+ loaded: dict[ReferenceId, Path] = {}
76
91
  for tgt in self.reference_map.targets():
92
+ path = Path(tgt)
93
+ if path not in t.fs:
94
+ continue
95
+ if path in t.db and t.fs[path].stat == t.db[path]:
96
+ log.debug(f"Skipping unchanged code: `{tgt}`")
97
+ continue
77
98
  log.debug(f"Reading code: `{tgt}`")
78
- if Path(tgt) in t.fs:
79
- self.load_code(t, Path(tgt))
99
+ self.load_code(t, path, loaded)
80
100
 
81
101
  def load(self, t: Transaction):
82
102
  files = get_input_files(t.fs, self.config)
@@ -12,10 +12,13 @@ import time
12
12
 
13
13
 
14
14
  def hexdigest(s: str) -> str:
15
- """Creates a MD5 hash digest from a string. Before hashing, the string has
16
- linefeed `\\r` characters and trailing newlines removed, and the string
17
- is encoded as UTF-8."""
18
- content = s.replace("\r", "").rstrip().encode()
15
+ """Creates a SHA-256 hash digest from a string. Before hashing, the string
16
+ has carriage return `\\r` characters, trailing white space on each line
17
+ and trailing newlines removed, and the string is encoded as UTF-8. This
18
+ way, changes in white space only, like the ones made by editors that
19
+ strip trailing white space on save, don't count as changes."""
20
+ lines = s.replace("\r", "").split("\n")
21
+ content = "\n".join(line.rstrip() for line in lines).rstrip().encode()
19
22
  return hashlib.sha256(content).hexdigest()
20
23
 
21
24
 
@@ -20,7 +20,7 @@ class WhitespaceStrippingConsole(Console):
20
20
  return "\n".join(line.rstrip() for line in rendered.splitlines()) + newline_char
21
21
 
22
22
  # Global rich console object
23
- console: Console = WhitespaceStrippingConsole()
23
+ console: Console = WhitespaceStrippingConsole(stderr=True)
24
24
 
25
25
  def logger():
26
26
  return logging.getLogger("entangled")
@@ -75,7 +75,9 @@ type Tangler = Callable[[Tangler, Deps, ReferenceId, bool, bool], Iterator[str]]
75
75
 
76
76
 
77
77
  def indent(prefix: str, g: Iterator[str]) -> Iterator[str]:
78
- return map(lambda line: "" if not line else prefix + line, g)
78
+ """Indent lines with `prefix`. Blank lines are not indented, so that they
79
+ don't get trailing white space."""
80
+ return map(lambda line: prefix + line if line.strip() else line.lstrip(" \t"), g)
79
81
 
80
82
 
81
83
  def naked_tangler(refs: ReferenceMap) -> Tangler:
@@ -48,7 +48,11 @@ def code_block_guard(origin: TextLocation, open_match: re.Match[str], close_matc
48
48
 
49
49
 
50
50
  def dedent_line(location: TextLocation, indent: str, line: str):
51
- if line.startswith(indent) or line.strip() == "":
51
+ # Blank lines lose all their white space, as they do when reading code
52
+ # back from a tangled file.
53
+ if line.strip() == "":
54
+ return line.lstrip(" \t")
55
+ if line.startswith(indent):
52
56
  return line.removeprefix(indent)
53
57
  raise IndentationError(location)
54
58
 
@@ -0,0 +1,9 @@
1
+ from .base import (
2
+ ContentFilter, CodeBlockFilter, codeblock_filter, compose_filters, iter_bind, transform_markdown
3
+ )
4
+ from .filters import filters, filter_description, block_title
5
+
6
+ __all__ = [
7
+ "ContentFilter", "CodeBlockFilter", "codeblock_filter", "compose_filters", "iter_bind",
8
+ "transform_markdown", "filters", "filter_description", "block_title"
9
+ ]
@@ -0,0 +1,66 @@
1
+ """
2
+ Generic machinery for weaving: transforming the Markdown source of a page,
3
+ code block by code block, into Markdown that renders well in some target.
4
+ """
5
+
6
+ from collections.abc import Callable, Iterable, Iterator
7
+ from functools import partial, reduce, wraps
8
+ from itertools import chain
9
+ from pathlib import PurePath
10
+
11
+ from ..interface import Context, markdown
12
+ from ..iterators import numbered_lines
13
+ from ..model import Content, PlainText, ReferenceId, ReferenceMap, content_to_text
14
+
15
+
16
+ type ContentFilter = Callable[[ReferenceMap, Content], Iterable[Content]]
17
+ type CodeBlockFilter = Callable[[ReferenceMap, ReferenceId], Iterable[Content]]
18
+
19
+
20
+ def codeblock_filter(f: CodeBlockFilter) -> ContentFilter:
21
+ """Lift a filter on code blocks to a filter on content, passing plain text
22
+ through unchanged."""
23
+ @wraps(f)
24
+ def _filter(rm: ReferenceMap, c: Content) -> Iterable[Content]:
25
+ match c:
26
+ case PlainText():
27
+ return [c]
28
+ case ReferenceId():
29
+ return f(rm, c)
30
+ return _filter
31
+
32
+
33
+ def iter_bind[T, U](lst: Iterable[T], f: Callable[[T], Iterable[U]]) -> Iterator[U]:
34
+ return chain.from_iterable(map(f, lst))
35
+
36
+
37
+ def compose_filter(a: ContentFilter, b: ContentFilter) -> ContentFilter:
38
+ def _joined(rm: ReferenceMap, c: Content) -> Iterable[Content]:
39
+ return iter_bind(a(rm, c), partial(b, rm))
40
+ return _joined
41
+
42
+
43
+ def compose_filters(*args: ContentFilter) -> ContentFilter:
44
+ """Compose filters left to right: the first filter is applied first."""
45
+ identity: ContentFilter = lambda _, c: [c]
46
+ return reduce(compose_filter, args, identity)
47
+
48
+
49
+ def transform_markdown(
50
+ context: Context,
51
+ text: str,
52
+ *filters: ContentFilter,
53
+ filename: PurePath = PurePath("-"),
54
+ reference_map: ReferenceMap | None = None,
55
+ ) -> Iterator[str]:
56
+ """
57
+ Read a Markdown document, pass its content through the given filters,
58
+ and yield the resulting Markdown text.
59
+
60
+ Filters may modify code blocks in the reference map in place. If no
61
+ `reference_map` is given, a fresh one is used for this document only.
62
+ """
63
+ refs = ReferenceMap() if reference_map is None else reference_map
64
+ content = markdown(context, refs, numbered_lines(filename, text))
65
+ for c in iter_bind(content, partial(compose_filters(*filters), refs)):
66
+ yield content_to_text(refs, c)[0]
@@ -0,0 +1,235 @@
1
+ """
2
+ Filters for `entangled weave`. Each filter is registered by name in `filters`;
3
+ the first line of its docstring serves as description for `--list-filters`.
4
+ """
5
+
6
+ from base64 import b64encode
7
+ from functools import lru_cache
8
+ from html import escape
9
+ from io import StringIO
10
+ from pathlib import Path
11
+ from textwrap import indent
12
+ import re
13
+
14
+ import msgspec
15
+
16
+ from repl_session import ReplCommand, ReplOutput, ReplSession, read_session
17
+
18
+ from ..hooks.repl import strip_comments
19
+ from ..logging import logger
20
+ from ..model import CodeBlock, Content, PlainText, ReferenceId, ReferenceMap
21
+ from ..model.properties import Attribute, Class, Id, get_attribute_string
22
+ from .base import ContentFilter, codeblock_filter
23
+
24
+
25
+ log = logger()
26
+
27
+
28
+ def block_title(code_block: CodeBlock) -> str | None:
29
+ """Compute a human readable title for a code block from its id and `file`
30
+ attribute. Returns `None` for anonymous code blocks."""
31
+ ids = [p.value for p in code_block.properties if isinstance(p, Id)]
32
+ filenames = [p.value for p in code_block.properties # pyright: ignore[reportAny]
33
+ if isinstance(p, Attribute) and p.key == "file"]
34
+
35
+ if len(ids) > 1 or len(filenames) > 1:
36
+ return "error: ambiguous code block title"
37
+ if ids and filenames:
38
+ return f"#{ids[0]} / file: {filenames[0]}"
39
+ if ids:
40
+ return f"#{ids[0]}"
41
+ if filenames:
42
+ return f"file: {filenames[0]}"
43
+ return None
44
+
45
+
46
+ def first_line(text: str) -> tuple[str, str]:
47
+ head, nl, rest = text.partition("\n")
48
+ return head, nl + rest
49
+
50
+
51
+ @codeblock_filter
52
+ def title(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
53
+ """Add a `title` attribute to the code block header (MkDocs/pymdownx style)."""
54
+ code_block = reference_map[r]
55
+ text = block_title(code_block)
56
+ ids = [p.value for p in code_block.properties if isinstance(p, Id)]
57
+ classes = [p.value for p in code_block.properties if isinstance(p, Class)]
58
+
59
+ head, rest = first_line(code_block.open_line)
60
+ fence_match = re.match(r"\s*(`{3,}|~{3,})", head)
61
+ fence = fence_match.group(1) if fence_match else "```"
62
+
63
+ attrs: list[str] = []
64
+ if ids:
65
+ attrs.append(str(Id(ids[0])))
66
+ attrs.extend(str(Class(c)) for c in classes[1:])
67
+ if text:
68
+ attrs.append(str(Attribute("title", text)))
69
+
70
+ head = fence + (classes[0] if classes else "")
71
+ if attrs:
72
+ head += " {" + " ".join(attrs) + "}"
73
+
74
+ code_block.open_line = head + (rest or "\n")
75
+ return [r]
76
+
77
+
78
+ @codeblock_filter
79
+ def label(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
80
+ """Wrap code blocks in a `<div>` with a label; renders in most Markdown previews."""
81
+ code_block = reference_map[r]
82
+ text = block_title(code_block)
83
+ if text is None:
84
+ return [r]
85
+
86
+ ind = code_block.indent
87
+ header = (f"{ind}<div class=\"entangled-code-block\" markdown=\"1\">\n"
88
+ f"{ind}<div class=\"entangled-code-title\"><code>{escape(text)}</code></div>\n\n")
89
+ # The closing fence may lack a newline at the end of a file. The blank
90
+ # lines around `</div>` end the HTML block in CommonMark.
91
+ eol = "" if code_block.close_line.endswith("\n") else "\n"
92
+ footer = f"{eol}\n{ind}</div>\n\n"
93
+ return [PlainText(header), r, PlainText(footer)]
94
+
95
+
96
+ # Representations to pick from a MIME bundle, most preferred first.
97
+ MIME_PRIORITY = [
98
+ "text/markdown", "text/html", "image/svg+xml", "image/png", "image/jpeg", "image/gif",
99
+ "text/plain",
100
+ ]
101
+
102
+
103
+ def fenced(text: str, classes: list[str]) -> str:
104
+ """Put text in a fenced code block, with a fence longer than any backtick
105
+ run inside the text."""
106
+ longest = max((len(m) for m in re.findall(r"`+", text)), default=0)
107
+ fence = "`" * max(3, longest + 1)
108
+ attrs = " ".join(str(Class(c)) for c in classes)
109
+ return f"{fence} {{{attrs}}}\n{text}\n{fence}"
110
+
111
+
112
+ def render_mime(mime: str, data: str, kind: str) -> str | None:
113
+ """Render a single representation as Markdown, or `None` if there is
114
+ nothing to show."""
115
+ match mime:
116
+ case "text/plain":
117
+ text = data.strip("\n")
118
+ if not text.strip():
119
+ return None
120
+ return fenced(text, ["text", "output"] + ([] if kind == "result" else [kind]))
121
+ case "text/markdown" | "text/html":
122
+ return data.strip("\n") or None
123
+ case "image/svg+xml":
124
+ return f"![](data:{mime};base64,{b64encode(data.encode()).decode()})"
125
+ case _ if mime.startswith("image/"):
126
+ return f"![](data:{mime};base64,{"".join(data.split())})"
127
+ case _:
128
+ return None
129
+
130
+
131
+ def render_output(output: ReplOutput, output_type: str) -> str | None:
132
+ """Render the most preferred representation in an output's MIME bundle.
133
+ A `mime-type` set on the code block, other than the default `text/plain`,
134
+ takes precedence."""
135
+ preferred = [] if output_type == "text/plain" else [output_type]
136
+ for mime in [*preferred, *MIME_PRIORITY]:
137
+ if mime in output.data:
138
+ return render_mime(mime, output.data[mime], output.kind)
139
+ return None
140
+
141
+
142
+ def command_outputs(command: ReplCommand) -> list[ReplOutput]:
143
+ """Outputs of a command; sessions from older versions of `repl-session`
144
+ only have the `output` field."""
145
+ if command.outputs is not None:
146
+ return command.outputs
147
+ if command.output is None:
148
+ return []
149
+ return [ReplOutput("result", {command.output_type: command.output})]
150
+
151
+
152
+ @lru_cache(maxsize=16)
153
+ def decode_session(text: str) -> ReplSession:
154
+ """Decode the output of a REPL session. Every code block in the session
155
+ needs it, so we cache on the contents of the file; modification times
156
+ are too coarse to tell when a file was rewritten."""
157
+ return read_session(StringIO(text))
158
+
159
+
160
+ def session_output(path: Path) -> ReplSession | None:
161
+ try:
162
+ return decode_session(path.read_text())
163
+ except FileNotFoundError:
164
+ log.warning("REPL session output `%s` not found; run `repl-session` first.", path)
165
+ except msgspec.MsgspecError as e:
166
+ log.warning("REPL session output `%s` could not be read: %s", path, e)
167
+ return None
168
+
169
+
170
+ def find_command(reference_map: ReferenceMap, r: ReferenceId) -> ReplCommand | None:
171
+ """Find the command for a code block in the output of its REPL session.
172
+ The session is opened by the first code block of the same name that has
173
+ the `repl` class and a `session` attribute; every code block of that name
174
+ from there on is a command in the session."""
175
+ refs = reference_map.select_by_name(r.name)
176
+ try:
177
+ start = next(i for i, ref in enumerate(refs)
178
+ if Class("repl") in reference_map[ref].properties
179
+ and get_attribute_string(reference_map[ref].properties, "session"))
180
+ except StopIteration:
181
+ return None
182
+
183
+ session_file = get_attribute_string(reference_map[refs[start]].properties, "session")
184
+ assert session_file is not None
185
+ session = session_output(Path(session_file).with_suffix(".out.json"))
186
+ if session is None:
187
+ return None
188
+
189
+ index = refs.index(r) - start
190
+ code_block = reference_map[r]
191
+ if not 0 <= index < len(session.commands):
192
+ log.warning("%s: no output for this code block in REPL session `%s`.",
193
+ code_block.origin, session_file)
194
+ return None
195
+
196
+ command = session.commands[index]
197
+ if code_block.language is not None \
198
+ and command.command.strip() != strip_comments(code_block.source, code_block.language).strip():
199
+ log.warning("%s: REPL session `%s` is out of date; tangle and run `repl-session` again.",
200
+ code_block.origin, session_file)
201
+ return None
202
+ return command
203
+
204
+
205
+ @codeblock_filter
206
+ def repl(reference_map: ReferenceMap, r: ReferenceId) -> list[Content]:
207
+ """Insert the output of REPL sessions (from the `repl` hook) after code blocks with the `repl` class."""
208
+ code_block = reference_map[r]
209
+ if Class("repl") not in code_block.properties:
210
+ return [r]
211
+
212
+ command = find_command(reference_map, r)
213
+ if command is None:
214
+ return [r]
215
+
216
+ rendered = [text for out in command_outputs(command)
217
+ if (text := render_output(out, command.output_type)) is not None]
218
+ if not rendered:
219
+ return [r]
220
+
221
+ # The closing fence may lack a newline at the end of a file.
222
+ eol = "" if code_block.close_line.endswith("\n") else "\n"
223
+ body = "".join(indent(f"\n{text}\n", code_block.indent) for text in rendered)
224
+ return [r, PlainText(eol + body)]
225
+
226
+
227
+ filters: dict[str, ContentFilter] = {
228
+ "label": label,
229
+ "repl": repl,
230
+ "title": title,
231
+ }
232
+
233
+
234
+ def filter_description(f: ContentFilter) -> str:
235
+ return (f.__doc__ or "").strip().partition("\n")[0]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "entangled-cli"
3
- version = "2.5.0"
3
+ version = "2.6.0"
4
4
  description = "Literate Programming toolbox"
5
5
  authors = [{ name = "Johan Hidding", email = "j.hidding@esciencecenter.nl" }]
6
6
  requires-python = ">=3.13,<4"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  "brei>=0.2.3,<0.3",
15
15
  "rich-argparse>=1.4.0,<2",
16
16
  "pyyaml>=6.0.1,<7",
17
- "repl-session>=0.2.0",
17
+ "repl-session>=0.3.0",
18
18
  "msgspec>=0.20.0",
19
19
  "click>=8.3.0",
20
20
  "rich-click>=1.9.4",
@@ -22,6 +22,11 @@ dependencies = [
22
22
  "watchfiles>=1.3",
23
23
  ]
24
24
 
25
+ [project.optional-dependencies]
26
+ jupyter = [
27
+ "repl-session[jupyter]>=0.3.0",
28
+ ]
29
+
25
30
  [project.urls]
26
31
  Homepage = "https://entangled.github.io/"
27
32
  Repository = "https://github.com/entangled/entangled.py"
@@ -47,6 +52,7 @@ dev = [
47
52
  "types-pexpect>=4.9.0.20250916",
48
53
  "types-PyYAML>=6.0.1,<7",
49
54
  "pytest-timeout>=2.4.0",
55
+ "ipykernel>=7.0",
50
56
  ]
51
57
 
52
58
  [tool.hatch.build.targets.sdist]
File without changes
File without changes