md-babel-py 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ivan Nikolic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,16 @@
1
+ Metadata-Version: 2.4
2
+ Name: md-babel-py
3
+ Version: 0.1.0
4
+ Summary: Execute code blocks in markdown files with session support
5
+ Author-email: Ivan Nikolic <lesh@mm.st>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest; extra == "dev"
11
+ Requires-Dist: ruff; extra == "dev"
12
+ Requires-Dist: mypy; extra == "dev"
13
+ Provides-Extra: matplotlib
14
+ Requires-Dist: matplotlib; extra == "matplotlib"
15
+ Requires-Dist: numpy; extra == "matplotlib"
16
+ Dynamic: license-file
@@ -0,0 +1,406 @@
1
+ # md-babel-py
2
+
3
+ Execute code blocks in markdown files and insert the results.
4
+
5
+ **Use cases:**
6
+ - Keep documentation examples up-to-date automatically
7
+ - Validate code snippets in docs actually work
8
+ - Generate diagrams and charts from code in markdown
9
+ - Literate programming with executable documentation
10
+
11
+ ## Languages
12
+
13
+ ### Shell
14
+
15
+ ```sh
16
+ echo "cwd: $(pwd)"
17
+ ```
18
+
19
+ <!--Result:-->
20
+ ```
21
+ cwd: /work
22
+ ```
23
+
24
+ ### Python
25
+
26
+ ```python session=example
27
+ a = "hello world"
28
+ print(a)
29
+ ```
30
+
31
+ <!--Result:-->
32
+ ```
33
+ hello world
34
+ ```
35
+
36
+ Sessions preserve state between code blocks:
37
+
38
+ ```python session=example
39
+ print(a, "again")
40
+ ```
41
+
42
+ <!--Result:-->
43
+ ```
44
+ hello world again
45
+ ```
46
+
47
+ ### Node.js
48
+
49
+ ```node
50
+ console.log("Hello from Node.js");
51
+ console.log(`Node version: ${process.version}`);
52
+ ```
53
+
54
+ <!--Result:-->
55
+ ```
56
+ Hello from Node.js
57
+ Node version: v22.21.1
58
+ ```
59
+
60
+ ### Matplotlib
61
+
62
+ ```python output=assets/matplotlib-demo.svg
63
+ import matplotlib.pyplot as plt
64
+ import numpy as np
65
+ plt.style.use('dark_background')
66
+ x = np.linspace(0, 4 * np.pi, 200)
67
+ plt.figure(figsize=(8, 4))
68
+ plt.plot(x, np.sin(x), label='sin(x)', linewidth=2)
69
+ plt.plot(x, np.cos(x), label='cos(x)', linewidth=2)
70
+ plt.xlabel('x')
71
+ plt.ylabel('y')
72
+ plt.legend()
73
+ plt.grid(alpha=0.3)
74
+ plt.savefig('{output}', transparent=True)
75
+ ```
76
+
77
+ <!--Result:-->
78
+ ![output](assets/matplotlib-demo.svg)
79
+
80
+ ### Pikchr
81
+
82
+ SQLite's diagram language:
83
+
84
+ ```pikchr output=assets/pikchr-demo.svg
85
+ color = white
86
+ fill = none
87
+ linewid = 0.4in
88
+
89
+ # Input file
90
+ In: file "README.md" fit
91
+ arrow
92
+
93
+ # Processing
94
+ Parse: box "Parse" rad 5px fit
95
+ arrow
96
+ Exec: box "Execute" rad 5px fit
97
+
98
+ # Fan out to languages
99
+ arrow from Exec.e right 0.3in then up 0.4in then right 0.3in
100
+ Sh: oval "Shell" fit
101
+ arrow from Exec.e right 0.3in then right 0.3in
102
+ Node: oval "Node" fit
103
+ arrow from Exec.e right 0.3in then down 0.4in then right 0.3in
104
+ Py: oval "Python" fit
105
+
106
+ # Merge back
107
+ arrow from Sh.e right 0.3in then down 0.4in then right 0.3in
108
+ arrow from Node.e right 0.6in
109
+ Out: file "README.md" fit with .w at last arrow.e
110
+ arrow from Py.e right 0.3in then up 0.4in then to Out.w
111
+ ```
112
+
113
+ <!--Result:-->
114
+ ![output](assets/pikchr-demo.svg)
115
+
116
+ ### Asymptote
117
+
118
+ Vector graphics:
119
+
120
+ ```asymptote output=assets/histogram.svg
121
+ import graph;
122
+ import stats;
123
+
124
+ size(400,200,IgnoreAspect);
125
+ defaultpen(white);
126
+
127
+ int n=10000;
128
+ real[] a=new real[n];
129
+ for(int i=0; i < n; ++i) a[i]=Gaussrand();
130
+
131
+ draw(graph(Gaussian,min(a),max(a)),orange);
132
+
133
+ int N=bins(a);
134
+
135
+ histogram(a,min(a),max(a),N,normalize=true,low=0,rgb(0.4,0.6,0.8),rgb(0.2,0.4,0.6),bars=true);
136
+
137
+ xaxis("$x$",BottomTop,LeftTicks,p=white);
138
+ yaxis("$dP/dx$",LeftRight,RightTicks(trailingzero),p=white);
139
+ ```
140
+
141
+ <!--Result:-->
142
+ ![output](assets/histogram.svg)
143
+
144
+ ### Graphviz
145
+
146
+ ```dot output=assets/graph.svg
147
+ A -> B -> C
148
+ A -> C
149
+ ```
150
+
151
+ <!--Result:-->
152
+ ![output](assets/graph.svg)
153
+
154
+ ### OpenSCAD
155
+
156
+ ```openscad output=assets/cube-sphere.png
157
+ cube([10, 10, 10]);
158
+ sphere(r=7);
159
+ ```
160
+
161
+ <!--Result:-->
162
+ ![output](assets/cube-sphere.png)
163
+
164
+ ### Diagon
165
+
166
+ ASCII art diagrams:
167
+
168
+ ```diagon mode=Math
169
+ 1 + 1/2 + sum(i,0,10)
170
+ ```
171
+
172
+ <!--Result:-->
173
+ ```
174
+ 10
175
+ ___
176
+ 1 ╲
177
+ 1 + ─ + ╱ i
178
+ 2 ‾‾‾
179
+ 0
180
+ ```
181
+
182
+ ```diagon mode=GraphDAG
183
+ A -> B -> C
184
+ A -> C
185
+ ```
186
+
187
+ <!--Result:-->
188
+ ```
189
+ ┌───┐
190
+ │A │
191
+ └┬─┬┘
192
+ │┌▽┐
193
+ ││B│
194
+ │└┬┘
195
+ ┌▽─▽┐
196
+ │C │
197
+ └───┘
198
+ ```
199
+
200
+ ## Install
201
+
202
+ ### Nix (recommended)
203
+
204
+ ```sh skip
205
+ # Run directly from GitHub
206
+ nix run github:leshy/md-babel-py -- run README.md --stdout
207
+
208
+ # Or clone and run locally
209
+ nix run . -- run README.md --stdout
210
+ ```
211
+
212
+ ### Docker
213
+
214
+ ```sh skip
215
+ # Pull from Docker Hub
216
+ docker run -v $(pwd):/work lesh/md-babel-py:main run /work/README.md --stdout
217
+
218
+ # Or build locally via Nix
219
+ nix build .#docker # builds tarball to ./result
220
+ docker load < result # loads image from tarball
221
+ docker run -v $(pwd):/work md-babel-py:latest run /work/file.md --stdout
222
+ ```
223
+
224
+ ### pipx
225
+
226
+ ```sh skip
227
+ pipx install md-babel-py
228
+ # or: uv pip install md-babel-py
229
+ md-babel-py run README.md --stdout
230
+ ```
231
+
232
+ If not using nix or docker, evaluators require system dependencies:
233
+
234
+ | Language | System packages |
235
+ |-----------|-----------------------------|
236
+ | python | python3 |
237
+ | node | nodejs |
238
+ | dot | graphviz |
239
+ | asymptote | asymptote, texlive, dvisvgm |
240
+ | pikchr | pikchr |
241
+ | openscad | openscad, xvfb, imagemagick |
242
+ | diagon | diagon |
243
+
244
+ ```sh skip
245
+ # Arch Linux
246
+ sudo pacman -S python nodejs graphviz asymptote texlive-basic openscad xorg-server-xvfb imagemagick
247
+
248
+ # Debian/Ubuntu
249
+ sudo apt-get install python3 nodejs graphviz asymptote texlive xvfb imagemagick openscad
250
+ ```
251
+
252
+ Note: pikchr and diagon may need to be built from source. Use Docker or Nix for full evaluator support.
253
+
254
+ ## Usage
255
+
256
+ ```sh skip
257
+ # Edit file in-place
258
+ md-babel-py run document.md
259
+
260
+ # Output to separate file
261
+ md-babel-py run document.md --output result.md
262
+
263
+ # Print to stdout
264
+ md-babel-py run document.md --stdout
265
+
266
+ # Only run specific languages
267
+ md-babel-py run document.md --lang python,sh
268
+
269
+ # Dry run - show what would execute
270
+ md-babel-py run document.md --dry-run
271
+ ```
272
+
273
+ ## Code Block Syntax
274
+
275
+ ````markdown
276
+ ```python session=main
277
+ x = 42
278
+ ```
279
+ ````
280
+
281
+ ### Flags
282
+
283
+ | Flag | Description |
284
+ |------------------|-----------------------------------------------------------|
285
+ | `session=NAME` | Share state with other blocks using the same session name |
286
+ | `output=PATH` | Write output to file (for images/diagrams) |
287
+ | `expected-error` | Expect this block to fail; test fails if it succeeds |
288
+ | `skip` | Don't execute this block |
289
+ | `no-result` | Execute but don't insert result block |
290
+
291
+ ### Custom Parameters
292
+
293
+ Any `key=value` pair becomes a parameter for the evaluator command:
294
+
295
+ ````markdown
296
+ ```diagon mode=GraphDAG
297
+ A -> B
298
+ ```
299
+ ````
300
+
301
+ With config `"defaultArguments": ["{mode}"]`, the `{mode}` placeholder is replaced with `GraphDAG`.
302
+
303
+ ## GitHub Action
304
+
305
+ ```yaml skip
306
+ - uses: leshy/md-babel-py@main
307
+ with:
308
+ files: 'README.md docs/*.md'
309
+ ```
310
+
311
+ | Input | Description | Default |
312
+ |------------------|-------------------------------------------|----------|
313
+ | `files` | Markdown files to process (glob patterns) | required |
314
+ | `args` | Additional arguments | `''` |
315
+ | `fail-on-change` | Fail if files were modified (CI check) | `false` |
316
+
317
+ Example with auto-commit:
318
+
319
+ ```yaml skip
320
+ - uses: leshy/md-babel-py@main
321
+ with:
322
+ files: '*.md docs/**/*.md'
323
+
324
+ - uses: stefanzweifel/git-auto-commit-action@v5
325
+ with:
326
+ commit_message: 'Update markdown code block results'
327
+ ```
328
+
329
+ ## Configuration
330
+
331
+ Create `config.json` in your project or `~/.config/md-babel/config.json`:
332
+
333
+ ```json skip
334
+ {
335
+ "evaluators": {
336
+ "codeBlock": {
337
+ "python": {
338
+ "path": "/usr/bin/env",
339
+ "defaultArguments": ["python3"],
340
+ "session": {
341
+ "command": ["python3", "-i"],
342
+ "prompts": [">>> ", "... "]
343
+ }
344
+ }
345
+ }
346
+ }
347
+ }
348
+ ```
349
+
350
+ ### File-based Evaluators
351
+
352
+ For tools that use input/output files:
353
+
354
+ ```json skip
355
+ {
356
+ "openscad": {
357
+ "path": "xvfb-run",
358
+ "defaultArguments": ["-a", "openscad", "-o", "{output_file}", "{input_file}"],
359
+ "inputExtension": ".scad"
360
+ }
361
+ }
362
+ ```
363
+
364
+ ## Development
365
+
366
+ ### direnv Setup
367
+
368
+ Two `.envrc` files are provided:
369
+
370
+ | File | Description |
371
+ |---------------|-------------------------------------------------|
372
+ | `.envrc.nix` | Nix flake devShell (all evaluators + dev tools) |
373
+ | `.envrc.venv` | Python venv only |
374
+
375
+ ```sh skip
376
+ ln -s .envrc.nix .envrc
377
+ direnv allow
378
+ ```
379
+
380
+ ### Nix Development Shell
381
+
382
+ ```sh skip
383
+ nix develop
384
+ # Provides: md-babel-py, pytest, mypy, ruff, and all evaluators
385
+ ```
386
+
387
+ ### Manual Setup
388
+
389
+ ```sh skip
390
+ pip install -e ".[dev]"
391
+ pytest tests/ -v
392
+ mypy md_babel_py/
393
+ ruff check md_babel_py/
394
+ ```
395
+
396
+ ### Nix Packages
397
+
398
+ | Package | Description |
399
+ |-----------|--------------------------------------------|
400
+ | `default` | Full package with all evaluators in PATH |
401
+ | `minimal` | Just Python package, no bundled evaluators |
402
+ | `docker` | Docker image tarball |
403
+
404
+ ## License
405
+
406
+ MIT
@@ -0,0 +1,3 @@
1
+ """md-babel-py: Execute code blocks in markdown files with session support."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,262 @@
1
+ """Command-line interface for md-babel-py."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from .config import load_config, get_evaluator, Config
9
+ from .exceptions import ConfigError, MdBabelError
10
+ from .executor import Executor
11
+ from .parser import find_code_blocks, CodeBlock
12
+ from .types import ExecutionResult
13
+ from .writer import apply_results, BlockResult
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def main() -> int:
19
+ """Main entry point for md-babel-py CLI."""
20
+ parser = argparse.ArgumentParser(
21
+ prog="md-babel-py",
22
+ description="Execute code blocks in markdown files",
23
+ )
24
+ parser.add_argument(
25
+ "-v", "--verbose",
26
+ action="store_true",
27
+ help="Enable verbose/debug output",
28
+ )
29
+
30
+ subparsers = parser.add_subparsers(dest="command", required=True)
31
+
32
+ # run command
33
+ run_parser = subparsers.add_parser("run", help="Execute code blocks in a markdown file")
34
+ run_parser.add_argument("file", type=Path, help="Markdown file to process")
35
+ run_parser.add_argument("--output", "-o", type=Path, help="Output file (default: edit in-place)")
36
+ run_parser.add_argument("--stdout", action="store_true", help="Print result to stdout instead of writing file")
37
+ run_parser.add_argument("--config", "-c", type=Path, help="Config file path")
38
+ run_parser.add_argument("--lang", help="Only execute these languages (comma-separated)")
39
+ run_parser.add_argument("--dry-run", action="store_true", help="Show what would be executed")
40
+
41
+ args = parser.parse_args()
42
+
43
+ # Setup logging
44
+ setup_logging(verbose=args.verbose)
45
+
46
+ try:
47
+ if args.command == "run":
48
+ return cmd_run(args)
49
+ return 0
50
+ except ConfigError as e:
51
+ logger.error(f"Configuration error: {e}")
52
+ return 1
53
+ except MdBabelError as e:
54
+ logger.error(f"Error: {e}")
55
+ return 1
56
+
57
+
58
+ def setup_logging(verbose: bool = False) -> None:
59
+ """Configure logging for the application.
60
+
61
+ Args:
62
+ verbose: If True, enable DEBUG level logging.
63
+ """
64
+ level = logging.DEBUG if verbose else logging.INFO
65
+ logging.basicConfig(
66
+ level=level,
67
+ format="%(message)s",
68
+ stream=sys.stderr,
69
+ )
70
+ # Quieter format for non-verbose
71
+ if not verbose:
72
+ logging.getLogger("md_babel_py").setLevel(logging.INFO)
73
+
74
+
75
+ def format_block_flags(block: CodeBlock) -> str:
76
+ """Format block flags for display.
77
+
78
+ Args:
79
+ block: The code block to format flags for.
80
+
81
+ Returns:
82
+ A formatted string like " [session=main, expected-error]" or empty string.
83
+ """
84
+ flags = []
85
+ if block.session:
86
+ flags.append(f"session={block.session}")
87
+ if block.expected_error:
88
+ flags.append("expected-error")
89
+ if block.no_result:
90
+ flags.append("no-result")
91
+ return f" [{', '.join(flags)}]" if flags else ""
92
+
93
+
94
+ def filter_blocks(
95
+ blocks: list[CodeBlock],
96
+ config: Config,
97
+ lang_filter: set[str] | None,
98
+ ) -> tuple[list[CodeBlock], set[str]]:
99
+ """Filter blocks by language and configuration.
100
+
101
+ Args:
102
+ blocks: All parsed code blocks.
103
+ config: The loaded configuration.
104
+ lang_filter: Optional set of languages to include.
105
+
106
+ Returns:
107
+ Tuple of (configured_blocks, unconfigured_languages).
108
+ """
109
+ # Filter by language if specified
110
+ if lang_filter:
111
+ blocks = [b for b in blocks if b.language in lang_filter]
112
+
113
+ # Separate configured from unconfigured
114
+ unconfigured: set[str] = set()
115
+ configured: list[CodeBlock] = []
116
+
117
+ for block in blocks:
118
+ if get_evaluator(config, block.language):
119
+ configured.append(block)
120
+ else:
121
+ unconfigured.add(block.language)
122
+
123
+ return configured, unconfigured
124
+
125
+
126
+ def execute_blocks(
127
+ executor: Executor,
128
+ blocks: list[CodeBlock],
129
+ ) -> tuple[list[BlockResult], list[str], bool]:
130
+ """Execute code blocks and collect results.
131
+
132
+ Args:
133
+ executor: The executor instance.
134
+ blocks: The blocks to execute.
135
+
136
+ Returns:
137
+ Tuple of (results, test_failures, stopped_early).
138
+ """
139
+ results: list[BlockResult] = []
140
+ test_failures: list[str] = []
141
+ stopped_early = False
142
+
143
+ for i, block in enumerate(blocks, 1):
144
+ flags_str = format_block_flags(block)
145
+ logger.info(f"[{i}/{len(blocks)}] Executing {block.language}{flags_str} block at line {block.start_line}...")
146
+
147
+ result = executor.execute(block)
148
+
149
+ # Only add to results if we want to write output (not no-result)
150
+ if not block.no_result:
151
+ results.append(BlockResult(block=block, result=result))
152
+
153
+ # Check expected-error logic
154
+ if block.expected_error:
155
+ if result.success:
156
+ msg = f"Line {block.start_line}: expected error but block succeeded"
157
+ test_failures.append(msg)
158
+ logger.error(f"FAIL: {msg}")
159
+ # Don't stop on expected errors
160
+ else:
161
+ if not result.success:
162
+ msg = f"Line {block.start_line}: {result.error_message or 'Execution failed'}"
163
+ test_failures.append(msg)
164
+ logger.error(f"Error: {result.error_message or 'Execution failed'}")
165
+ if result.stderr:
166
+ logger.error(result.stderr)
167
+ # Stop on first unexpected error
168
+ stopped_early = True
169
+ break
170
+
171
+ return results, test_failures, stopped_early
172
+
173
+
174
+ def cmd_run(args: argparse.Namespace) -> int:
175
+ """Execute the run command.
176
+
177
+ Args:
178
+ args: Parsed command-line arguments.
179
+
180
+ Returns:
181
+ Exit code (0 for success, non-zero for failure).
182
+ """
183
+ # Load config
184
+ config = load_config(args.config)
185
+
186
+ # Read input file
187
+ if not args.file.exists():
188
+ logger.error(f"Error: File not found: {args.file}")
189
+ return 1
190
+
191
+ content = args.file.read_text()
192
+
193
+ # Parse code blocks
194
+ blocks = find_code_blocks(content)
195
+
196
+ if not blocks:
197
+ logger.info("No code blocks found.")
198
+ return 0
199
+
200
+ # Parse language filter
201
+ lang_filter = set(args.lang.split(",")) if args.lang else None
202
+
203
+ # Filter blocks
204
+ configured_blocks, unconfigured = filter_blocks(blocks, config, lang_filter)
205
+
206
+ if unconfigured:
207
+ logger.warning(f"Warning: No evaluators configured for: {', '.join(sorted(unconfigured))}")
208
+
209
+ if not configured_blocks:
210
+ logger.info("No executable code blocks found.")
211
+ return 0
212
+
213
+ # Filter out skipped blocks
214
+ executable_blocks = [b for b in configured_blocks if not b.skip]
215
+ skipped_count = len(configured_blocks) - len(executable_blocks)
216
+
217
+ # Dry run - just show what would execute
218
+ if args.dry_run:
219
+ logger.info(f"Would execute {len(executable_blocks)} code block(s):\n")
220
+ for i, block in enumerate(executable_blocks, 1):
221
+ flags_str = format_block_flags(block)
222
+ logger.info(f"{i}. {block.language}{flags_str} (lines {block.start_line}-{block.end_line})")
223
+ logger.info(f" {block.code[:50]}{'...' if len(block.code) > 50 else ''}")
224
+ logger.info("")
225
+ if skipped_count:
226
+ logger.info(f"({skipped_count} block(s) marked as skip)")
227
+ return 0
228
+
229
+ # Execute blocks
230
+ executor = Executor(config)
231
+ try:
232
+ results, test_failures, _ = execute_blocks(executor, executable_blocks)
233
+ finally:
234
+ executor.cleanup()
235
+
236
+ # Apply results to content
237
+ new_content = apply_results(content, results)
238
+
239
+ # Write output
240
+ if args.stdout:
241
+ print(new_content)
242
+ else:
243
+ output_path = args.output or args.file
244
+ output_path.write_text(new_content)
245
+
246
+ success_count = sum(1 for r in results if r.result.success)
247
+ logger.info(f"\nDone: {success_count}/{len(results)} blocks executed successfully.")
248
+
249
+ if not args.stdout and args.output and args.output != args.file:
250
+ logger.info(f"Output written to: {args.output}")
251
+
252
+ if test_failures:
253
+ logger.error(f"\n{len(test_failures)} test failure(s):")
254
+ for f in test_failures:
255
+ logger.error(f" - {f}")
256
+ return 1
257
+
258
+ return 0
259
+
260
+
261
+ if __name__ == "__main__":
262
+ sys.exit(main())