importpython-markdown 0.0.1__py3-none-any.whl → 0.1.0__py3-none-any.whl

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.
@@ -1,5 +1,431 @@
1
1
  """
2
- importpython-markdown
2
+ Runnable Python examples in Markdown, for any renderer built on
3
+ Python-Markdown, powered by importpython.org.
4
+
5
+ A region between a ``#!`` gate line and a ``!#`` gate line
6
+ becomes one ``<import-python>`` element. Inside the gates,
7
+ ordinary Markdown is rendered as captions, and fenced code
8
+ blocks map onto the element's parts: a plain fence is the
9
+ program, ``title="name"`` on a fence is a file delivered beside
10
+ it, and ``config`` and ``body`` fences carry BFP's settings and
11
+ the frame's page markup. The plugin escapes what needs escaping,
12
+ so a documenter never types an entity, and it validates at build
13
+ time in the element's own vocabulary, so an authoring mistake is
14
+ a loud build failure rather than a silent page.
15
+
16
+ The emitted markup follows the element's contract exactly: each
17
+ part is a bare ``<pre>``, a direct child of the element, in the
18
+ order the author wrote it.
3
19
  """
4
20
 
5
- __version__ = "0.0.1"
21
+ import html
22
+ import json
23
+ import re
24
+
25
+ from markdown import Markdown
26
+ from pygments import highlight
27
+ from pygments.formatters import HtmlFormatter
28
+ from pygments.lexers import get_lexer_by_name, get_lexer_for_filename
29
+ from pygments.util import ClassNotFound
30
+ from markdown.extensions import Extension
31
+ from markdown.preprocessors import Preprocessor
32
+
33
+ # The gates. A #! line opens an example and carries its header;
34
+ # a line that is exactly !# closes it. Both are recognised only
35
+ # outside fenced code, so code about the gates can be quoted.
36
+ OPEN_GATE = re.compile(r"^#!(\S+)(?:\s+(.*))?$")
37
+ CLOSE_GATE = re.compile(r"^!#\s*$")
38
+
39
+ # A fence opener or closer: three or more backticks or tildes.
40
+ # The closer must use the same character, at least as many, and
41
+ # carry nothing else - the CommonMark rules this plugin needs.
42
+ FENCE = re.compile(r"^(`{3,}|~{3,})\s*(.*)$")
43
+
44
+ # Attributes on the gate line: bare flags and key="value" pairs.
45
+ ATTRIBUTE = re.compile(r'([\w-]+)(?:="([^"]*)")?')
46
+
47
+ # The element's runtimes, and what the gate may pass through to
48
+ # it. The allow-list is deliberate: a typo in an attribute should
49
+ # fail the build, not be silently ignored in every reader's
50
+ # browser.
51
+ TYPES = ("py", "mpy")
52
+ FLAGS = ("terminal", "debug")
53
+ VALUED = ("title", "run-label", "stop-label", "theme", "class", "id")
54
+
55
+
56
+ class ExampleError(ValueError):
57
+ """
58
+ An authoring mistake in an example, raised so the site build
59
+ fails loudly and names the problem - the documenter's version
60
+ of the element withholding its button.
61
+ """
62
+
63
+ def __init__(self, line, problem):
64
+ super().__init__(f"import-python example, line {line}: {problem}")
65
+
66
+
67
+ def parse_gate(header, line):
68
+ """
69
+ Turn a gate header into the element's attributes, insisting on
70
+ a known runtime first and allow-listed attributes after it, so
71
+ every mistake is caught here rather than shipped.
72
+ """
73
+ match = OPEN_GATE.match(header)
74
+ kind = match.group(1)
75
+ if kind not in TYPES:
76
+ raise ExampleError(
77
+ line, f'unknown type "{kind}"; use one of {", ".join(TYPES)}'
78
+ )
79
+ attributes = {"type": kind}
80
+ rest = match.group(2) or ""
81
+ span = 0
82
+ for found in ATTRIBUTE.finditer(rest):
83
+ if rest[span:found.start()].strip():
84
+ raise ExampleError(
85
+ line, f'cannot parse attributes at "{rest[span:]}"'
86
+ )
87
+ span = found.end()
88
+ name, value = found.group(1), found.group(2)
89
+ if value is None:
90
+ if name not in FLAGS:
91
+ raise ExampleError(
92
+ line,
93
+ f'unknown flag "{name}"; '
94
+ f'flags are {", ".join(FLAGS)}',
95
+ )
96
+ attributes[name] = None
97
+ else:
98
+ if name not in VALUED:
99
+ raise ExampleError(
100
+ line,
101
+ f'unknown attribute "{name}"; '
102
+ f'attributes are {", ".join(VALUED)}',
103
+ )
104
+ attributes[name] = value
105
+ if rest[span:].strip():
106
+ raise ExampleError(
107
+ line, f'cannot parse attributes at "{rest[span:]}"'
108
+ )
109
+ return attributes
110
+
111
+
112
+ def parse_fence_info(info):
113
+ """
114
+ Decide what a fence inside the gates is for. A title makes it
115
+ a file; the reserved words config and body claim their roles;
116
+ anything else - a language name or nothing - is the program.
117
+ The language is kept for the highlighter to use later.
118
+ """
119
+ title = None
120
+ found = re.search(r'title="([^"]*)"', info)
121
+ if found:
122
+ title = found.group(1)
123
+ info = (info[:found.start()] + info[found.end():]).strip()
124
+ language = info.split()[0] if info.split() else None
125
+ if title is not None:
126
+ return {"part": "file", "title": title, "language": language}
127
+ if language in ("config", "body"):
128
+ return {"part": language, "language": None}
129
+ return {"part": "program", "language": language}
130
+
131
+
132
+ def split_example(lines, first_line):
133
+ """
134
+ Walk the region between the gates, separating fenced blocks
135
+ (the element's parts) from the prose between them (the
136
+ captions), preserving the author's order throughout.
137
+ """
138
+ pieces = []
139
+ prose = []
140
+ fence = None
141
+ block = None
142
+ for offset, line in enumerate(lines):
143
+ if fence is None:
144
+ found = FENCE.match(line)
145
+ if found:
146
+ if prose and any(p.strip() for p in prose):
147
+ pieces.append({"part": "prose", "text": prose})
148
+ prose = []
149
+ fence = found.group(1)
150
+ block = parse_fence_info(found.group(2).strip())
151
+ block["text"] = []
152
+ block["line"] = first_line + offset
153
+ continue
154
+ prose.append(line)
155
+ else:
156
+ found = FENCE.match(line)
157
+ closes = (
158
+ found
159
+ and found.group(1)[0] == fence[0]
160
+ and len(found.group(1)) >= len(fence)
161
+ and not found.group(2).strip()
162
+ )
163
+ if closes:
164
+ pieces.append(block)
165
+ fence = None
166
+ block = None
167
+ continue
168
+ block["text"].append(line)
169
+ if fence is not None:
170
+ raise ExampleError(block["line"], "unclosed code fence")
171
+ if prose and any(p.strip() for p in prose):
172
+ pieces.append({"part": "prose", "text": prose})
173
+ return pieces
174
+
175
+
176
+ def validate(pieces, line):
177
+ """
178
+ The element's own rules, applied at build time: exactly one
179
+ program, no duplicate filenames, at most one config (as valid
180
+ JSON) and one body. The wording follows classify() in
181
+ embed.js, so both layers speak with one voice.
182
+ """
183
+ programs = [p for p in pieces if p["part"] == "program"]
184
+ if not programs:
185
+ raise ExampleError(line, "no program block")
186
+ if len(programs) > 1:
187
+ raise ExampleError(programs[1]["line"], "more than one program block")
188
+ names = set()
189
+ for piece in [p for p in pieces if p["part"] == "file"]:
190
+ if piece["title"] in names:
191
+ raise ExampleError(
192
+ piece["line"],
193
+ f'two blocks name the file "{piece["title"]}"',
194
+ )
195
+ names.add(piece["title"])
196
+ for role in ("config", "body"):
197
+ found = [p for p in pieces if p["part"] == role]
198
+ if len(found) > 1:
199
+ raise ExampleError(
200
+ found[1]["line"], f"more than one {role} block"
201
+ )
202
+ for piece in [p for p in pieces if p["part"] == "config"]:
203
+ try:
204
+ json.loads("\n".join(piece["text"]))
205
+ except json.JSONDecodeError as error:
206
+ raise ExampleError(
207
+ piece["line"],
208
+ f"the config block is not valid JSON: {error}",
209
+ ) from None
210
+
211
+
212
+ class ImportPythonPreprocessor(Preprocessor):
213
+ """
214
+ Find every gated region, build its element, and stash the
215
+ result so the surrounding renderer treats it as opaque HTML.
216
+ Fence state is tracked at the top level too, so gates quoted
217
+ inside ordinary code blocks stay code.
218
+ """
219
+
220
+ def __init__(self, md, config):
221
+ super().__init__(md)
222
+ self.config = config
223
+
224
+ def run(self, lines):
225
+ out = []
226
+ example = None
227
+ first = 0
228
+ fence = None
229
+ emitted = False
230
+ for number, line in enumerate(lines, start=1):
231
+ if example is None:
232
+ found = FENCE.match(line)
233
+ if fence is None and OPEN_GATE.match(line):
234
+ example = []
235
+ first = number
236
+ continue
237
+ if found:
238
+ if fence is None:
239
+ fence = found.group(1)
240
+ elif (
241
+ found.group(1)[0] == fence[0]
242
+ and len(found.group(1)) >= len(fence)
243
+ and not found.group(2).strip()
244
+ ):
245
+ fence = None
246
+ out.append(line)
247
+ else:
248
+ if CLOSE_GATE.match(line) and not self.inside_fence(
249
+ example
250
+ ):
251
+ element = self.render(
252
+ lines[first - 1], example, first
253
+ )
254
+ out.append("")
255
+ out.append(self.md.htmlStash.store(element))
256
+ out.append("")
257
+ example = None
258
+ emitted = True
259
+ continue
260
+ example.append(line)
261
+ if example is not None:
262
+ raise ExampleError(first, "unclosed #! gate: no matching !#")
263
+ if emitted and self.config["inject_script"]:
264
+ # As a literal line rather than a stash entry: script
265
+ # opens an HTML block, so Markdown passes it through
266
+ # unwrapped, where a stashed placeholder at document
267
+ # end can be left inside a paragraph.
268
+ out.append("")
269
+ out.append(
270
+ '<script type="module" src="'
271
+ + html.escape(self.config["script_url"])
272
+ + '"></script>'
273
+ )
274
+ out.append("")
275
+ return out
276
+
277
+ def inside_fence(self, lines):
278
+ """
279
+ Whether the region so far ends inside a fenced block, in
280
+ which case a !# line is content rather than the gate.
281
+ """
282
+ fence = None
283
+ for line in lines:
284
+ found = FENCE.match(line)
285
+ if not found:
286
+ continue
287
+ if fence is None:
288
+ fence = found.group(1)
289
+ elif (
290
+ found.group(1)[0] == fence[0]
291
+ and len(found.group(1)) >= len(fence)
292
+ and not found.group(2).strip()
293
+ ):
294
+ fence = None
295
+ return fence is not None
296
+
297
+ def render(self, header, lines, first_line):
298
+ """
299
+ One example: parse, validate, and emit the element with
300
+ its parts and captions in the author's order.
301
+ """
302
+ attributes = parse_gate(header, first_line)
303
+ pieces = split_example(lines, first_line + 1)
304
+ validate(pieces, first_line)
305
+ parts = ["<import-python"]
306
+ for name, value in attributes.items():
307
+ if value is None:
308
+ parts.append(f" {name}")
309
+ else:
310
+ parts.append(f' {name}="{html.escape(value)}"')
311
+ parts.append(">\n")
312
+ for piece in pieces:
313
+ parts.append(self.render_piece(piece))
314
+ parts.append("</import-python>")
315
+ return "".join(parts)
316
+
317
+ def render_piece(self, piece):
318
+ """
319
+ A caption renders as Markdown through a fresh renderer,
320
+ so the example's own gates cannot nest; a part becomes a
321
+ bare pre, a direct child - the element's contract - with
322
+ its text either highlighted or escaped, and never both by
323
+ hand: Pygments escapes what it colours, and the plain
324
+ path escapes here, so the author never types an entity.
325
+ That is also what makes a body block safe to write as
326
+ plain markup.
327
+ """
328
+ if piece["part"] == "prose":
329
+ inner = Markdown(
330
+ extensions=self.config["inner_extensions"]
331
+ )
332
+ return inner.convert("\n".join(piece["text"])) + "\n"
333
+ text, lit = self.paint("\n".join(piece["text"]), piece)
334
+ if piece["part"] == "file":
335
+ title = html.escape(piece["title"])
336
+ return f'<pre{lit} title="{title}">\n{text}\n</pre>\n'
337
+ if piece["part"] in ("config", "body"):
338
+ role = piece["part"]
339
+ return f'<pre{lit} data-role="{role}">\n{text}\n</pre>\n'
340
+ return f"<pre{lit}>\n{text}\n</pre>\n"
341
+
342
+ def paint(self, code, piece):
343
+ """
344
+ Highlight a part's code as Pygments spans with no wrapper
345
+ of any kind, so the pre stays a bare direct child and its
346
+ textContent stays exactly the code. The class="highlight"
347
+ the coloured path adds is what lets a Material or
348
+ Zensical site's existing Pygments stylesheet colour these
349
+ blocks, palettes and all, with no CSS of ours.
350
+
351
+ The language comes from the fence, or a file's name, with
352
+ json and html assumed for config and body; an unknown or
353
+ absent language falls back to plain escaped text, as the
354
+ wider ecosystem does, because colour is decoration rather
355
+ than contract.
356
+ """
357
+ lexer = None
358
+ if self.config["highlight"]:
359
+ language = piece.get("language")
360
+ if piece["part"] == "config":
361
+ language = "json"
362
+ elif piece["part"] == "body":
363
+ language = "html"
364
+ try:
365
+ if language:
366
+ lexer = get_lexer_by_name(language)
367
+ elif piece["part"] == "file":
368
+ lexer = get_lexer_for_filename(piece["title"])
369
+ except ClassNotFound:
370
+ lexer = None
371
+ if lexer is None:
372
+ return html.escape(code, quote=False), ""
373
+ painted = highlight(
374
+ code, lexer, HtmlFormatter(nowrap=True)
375
+ ).rstrip("\n")
376
+ return painted, ' class="highlight"'
377
+
378
+
379
+ class ImportPythonExtension(Extension):
380
+ """
381
+ The extension: register the preprocessor early, before other
382
+ preprocessors can mistake the gated region for their own.
383
+ """
384
+
385
+ def __init__(self, **kwargs):
386
+ self.config = {
387
+ "script_url": [
388
+ "https://importpython.org/bfp/test/embed.js",
389
+ "Where embed.js is served from; point this at "
390
+ "your own copy to use one.",
391
+ ],
392
+ "inject_script": [
393
+ True,
394
+ "Append one script tag to any page that carries "
395
+ "an example. Turn off for sites that load "
396
+ "embed.js themselves.",
397
+ ],
398
+ "inner_extensions": [
399
+ [],
400
+ "Markdown extensions applied to the captions "
401
+ "inside an example.",
402
+ ],
403
+ "highlight": [
404
+ True,
405
+ "Colour the code with Pygments spans inside the "
406
+ "bare pre, marked class=\"highlight\" so a "
407
+ "site's existing Pygments stylesheet applies.",
408
+ ],
409
+ }
410
+ super().__init__(**kwargs)
411
+
412
+ def extendMarkdown(self, md):
413
+ # The element must be known as block-level, or the raw
414
+ # HTML postprocessor leaves the stashed element wrapped
415
+ # in a paragraph - and a pre inside a p makes browsers
416
+ # close the paragraph early, structurally breaking the
417
+ # direct-child contract the plugin exists to honour.
418
+ md.block_level_elements.append("import-python")
419
+ md.preprocessors.register(
420
+ ImportPythonPreprocessor(md, self.getConfigs()),
421
+ "import-python",
422
+ 28,
423
+ )
424
+
425
+
426
+ def makeExtension(**kwargs):
427
+ """
428
+ The hook Python-Markdown looks for when the extension is
429
+ named by its module path.
430
+ """
431
+ return ImportPythonExtension(**kwargs)
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: importpython-markdown
3
+ Version: 0.1.0
4
+ Summary: Runnable Python examples in Markdown, via importpython.org.
5
+ Author-email: "Nicholas H.Tollervey" <ntoll@ntoll.org>
6
+ License: MIT
7
+ Project-URL: Homepage, https://importpython.org/
8
+ Project-URL: Repository, https://codeberg.org/ntoll/importpython
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Documentation
14
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: markdown>=3.4
19
+ Requires-Dist: pygments>=2.14
20
+ Dynamic: license-file
21
+
22
+ # importpython-markdown
23
+
24
+ Runnable Python examples in Markdown pages, for MkDocs, Zensical
25
+ and anything else built on Python-Markdown, powered by
26
+ [importpython.org](https://importpython.org/).
27
+
28
+ Wrap a section of ordinary Markdown in `#!` and `!#` gates:
29
+
30
+ #!mpy terminal title="A first example"
31
+
32
+ A caption, rendered as normal Markdown.
33
+
34
+ ```python
35
+ print("Hello, world!")
36
+ ```
37
+
38
+ !#
39
+
40
+ The section becomes a runnable example: the code, a Run button,
41
+ and the program's output beneath - nothing downloaded until a
42
+ reader presses Run. Plain fences are the program; a fence with
43
+ `title="name.py"` is a file delivered beside it; `config` and
44
+ `body` fences carry BFP settings and page markup (escaped for
45
+ you). The `py` and `mpy` types choose Pyodide or MicroPython,
46
+ and `terminal`, `debug`, `title="..."`, `run-label="..."`,
47
+ `stop-label="..."`, `theme="..."`, `class` and `id` pass through
48
+ to the element.
49
+
50
+ Install and enable:
51
+
52
+ pip install importpython-markdown
53
+
54
+ ```yaml
55
+ # mkdocs.yml
56
+ markdown_extensions:
57
+ - importpython
58
+ ```
59
+
60
+ ```toml
61
+ # zensical.toml
62
+ [project.markdown_extensions]
63
+ importpython = {}
64
+ ```
65
+
66
+ Pre-release honesty: until BFP's first release, the default
67
+ `script_url` points at the service's test channel, which may
68
+ change; pin your own copy with the `script_url` option if that
69
+ matters to you today.
70
+
71
+ The full documentation lives at
72
+ [importpython.org/docs](https://importpython.org/docs/).
@@ -0,0 +1,7 @@
1
+ importpython_markdown/__init__.py,sha256=hT7LSHnfozBbaNInQD6vsTC5dO4EcKRV9KY2zLBjyw8,15955
2
+ importpython_markdown-0.1.0.dist-info/licenses/LICENSE,sha256=ksfiPPZ3bBB4_KgmDo6-NLda44B_-onAZ3JLVxYSIVg,1077
3
+ importpython_markdown-0.1.0.dist-info/METADATA,sha256=uzuRuKzAGStQHOnMMAKbp6G4iayeKl8SzOJLDpqt8Rk,2179
4
+ importpython_markdown-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ importpython_markdown-0.1.0.dist-info/entry_points.txt,sha256=0Fo68SFJa3rmdmuysJyRTCu35rve3xGdegmhOutPs18,81
6
+ importpython_markdown-0.1.0.dist-info/top_level.txt,sha256=gZT7U42MZK8PK1UxWpt0IGHGv8ynHvdYlxn-OjZRQ-0,22
7
+ importpython_markdown-0.1.0.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: setuptools (84.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [markdown.extensions]
2
+ importpython = importpython_markdown:ImportPythonExtension
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nicholas H.Tollervey
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
13
+ all 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
21
+ THE SOFTWARE.
@@ -0,0 +1 @@
1
+ importpython_markdown
@@ -1,7 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: importpython-markdown
3
- Version: 0.0.1
4
- Summary: A markdown plugin to use importpython in docs.
5
- Author-email: "Nicholas H.Tollervey" <ntoll@ntoll.org>
6
- License-Expression: MIT
7
- Requires-Python: >=3.11
@@ -1,4 +0,0 @@
1
- importpython_markdown/__init__.py,sha256=hWrpMAM0x9bx9Wfg7DDdLNr7neoKJwcgs5Ilfb3gYi0,53
2
- importpython_markdown-0.0.1.dist-info/METADATA,sha256=Q9RMRdm8VIn1qAKr58FR7x7YaikPMJe38bckj6w5q4E,224
3
- importpython_markdown-0.0.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
4
- importpython_markdown-0.0.1.dist-info/RECORD,,