importpython-markdown 0.1.0__tar.gz → 0.1.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: importpython-markdown
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Runnable Python examples in Markdown, via importpython.org.
5
5
  Author-email: "Nicholas H.Tollervey" <ntoll@ntoll.org>
6
6
  License: MIT
@@ -43,9 +43,11 @@ reader presses Run. Plain fences are the program; a fence with
43
43
  `title="name.py"` is a file delivered beside it; `config` and
44
44
  `body` fences carry BFP settings and page markup (escaped for
45
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.
46
+ and `terminal`, `debug`, `send-embedder-url`, `title="..."`,
47
+ `run-label="..."`, `stop-label="..."`, `theme="..."`,
48
+ `terminal-rows="..."`, `terminal-cols="..."`, `class` and `id`
49
+ pass through to the element - each documented at
50
+ [importpython.org](https://importpython.org/docs/writing-markdown/).
49
51
 
50
52
  Install and enable:
51
53
 
@@ -22,9 +22,11 @@ reader presses Run. Plain fences are the program; a fence with
22
22
  `title="name.py"` is a file delivered beside it; `config` and
23
23
  `body` fences carry BFP settings and page markup (escaped for
24
24
  you). The `py` and `mpy` types choose Pyodide or MicroPython,
25
- and `terminal`, `debug`, `title="..."`, `run-label="..."`,
26
- `stop-label="..."`, `theme="..."`, `class` and `id` pass through
27
- to the element.
25
+ and `terminal`, `debug`, `send-embedder-url`, `title="..."`,
26
+ `run-label="..."`, `stop-label="..."`, `theme="..."`,
27
+ `terminal-rows="..."`, `terminal-cols="..."`, `class` and `id`
28
+ pass through to the element - each documented at
29
+ [importpython.org](https://importpython.org/docs/writing-markdown/).
28
30
 
29
31
  Install and enable:
30
32
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "importpython-markdown"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Runnable Python examples in Markdown, via importpython.org."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -14,8 +14,11 @@ time in the element's own vocabulary, so an authoring mistake is
14
14
  a loud build failure rather than a silent page.
15
15
 
16
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.
17
+ part is a ``<pre>`` holding one ``<code>``, the pre a direct
18
+ child of the element, in the order the author wrote it. The
19
+ element reads a ``<pre><code>`` pair and a bare ``<pre>``
20
+ identically; the inner ``<code>`` is for the surrounding theme,
21
+ whose block styling and copy button expect it.
19
22
  """
20
23
 
21
24
  import html
@@ -49,8 +52,9 @@ ATTRIBUTE = re.compile(r'([\w-]+)(?:="([^"]*)")?')
49
52
  # fail the build, not be silently ignored in every reader's
50
53
  # browser.
51
54
  TYPES = ("py", "mpy")
52
- FLAGS = ("terminal", "debug")
53
- VALUED = ("title", "run-label", "stop-label", "theme", "class", "id")
55
+ FLAGS = ("terminal", "debug", "send-embedder-url")
56
+ VALUED = ("title", "run-label", "stop-label", "theme", "class",
57
+ "id", "terminal-rows", "terminal-cols")
54
58
 
55
59
 
56
60
  class ExampleError(ValueError):
@@ -106,6 +110,23 @@ def parse_gate(header, line):
106
110
  raise ExampleError(
107
111
  line, f'cannot parse attributes at "{rest[span:]}"'
108
112
  )
113
+ # The terminal's explicit size: each dimension needs the
114
+ # terminal it sizes, and only a positive whole number will do
115
+ # - the same rules the element applies, failing the build
116
+ # here instead of a reader's browser there.
117
+ for name in ("terminal-rows", "terminal-cols"):
118
+ if name in attributes:
119
+ if "terminal" not in attributes:
120
+ raise ExampleError(
121
+ line, f"{name} requires the terminal flag"
122
+ )
123
+ value = attributes[name]
124
+ if not (value.isdigit() and int(value) >= 1):
125
+ raise ExampleError(
126
+ line,
127
+ f'{name} is "{value}"; use a positive whole '
128
+ "number",
129
+ )
109
130
  return attributes
110
131
 
111
132
 
@@ -173,7 +194,7 @@ def split_example(lines, first_line):
173
194
  return pieces
174
195
 
175
196
 
176
- def validate(pieces, line):
197
+ def validate(pieces, attributes, line):
177
198
  """
178
199
  The element's own rules, applied at build time: exactly one
179
200
  program, no duplicate filenames, at most one config (as valid
@@ -201,12 +222,37 @@ def validate(pieces, line):
201
222
  )
202
223
  for piece in [p for p in pieces if p["part"] == "config"]:
203
224
  try:
204
- json.loads("\n".join(piece["text"]))
225
+ settings = json.loads("\n".join(piece["text"]))
205
226
  except json.JSONDecodeError as error:
206
227
  raise ExampleError(
207
228
  piece["line"],
208
229
  f"the config block is not valid JSON: {error}",
209
230
  ) from None
231
+ # send-embedder-url writes importpython.py, so while the
232
+ # flag is on the name is reserved - here in the author's
233
+ # own config files too, matching the element's rule.
234
+ if "send-embedder-url" in attributes:
235
+ names = (settings.get("files") or {}).values()
236
+ if any(
237
+ str(name).removeprefix("./") == "importpython.py"
238
+ for name in names
239
+ ):
240
+ raise ExampleError(
241
+ piece["line"],
242
+ "the send-embedder-url flag reserves the "
243
+ 'file "importpython.py"',
244
+ )
245
+ if "send-embedder-url" in attributes:
246
+ for piece in pieces:
247
+ if (
248
+ piece["part"] == "file"
249
+ and piece["title"] == "importpython.py"
250
+ ):
251
+ raise ExampleError(
252
+ piece["line"],
253
+ "the send-embedder-url flag reserves the "
254
+ 'file "importpython.py"',
255
+ )
210
256
 
211
257
 
212
258
  class ImportPythonPreprocessor(Preprocessor):
@@ -301,7 +347,7 @@ class ImportPythonPreprocessor(Preprocessor):
301
347
  """
302
348
  attributes = parse_gate(header, first_line)
303
349
  pieces = split_example(lines, first_line + 1)
304
- validate(pieces, first_line)
350
+ validate(pieces, attributes, first_line)
305
351
  parts = ["<import-python"]
306
352
  for name, value in attributes.items():
307
353
  if value is None:
@@ -331,22 +377,29 @@ class ImportPythonPreprocessor(Preprocessor):
331
377
  )
332
378
  return inner.convert("\n".join(piece["text"])) + "\n"
333
379
  text, lit = self.paint("\n".join(piece["text"]), piece)
380
+ # One code element inside every pre, highlighted or not:
381
+ # MkDocs-family themes hang block styling and the copy
382
+ # button on pre > code, and the element reads both shapes
383
+ # identically. No newline before the text - a pre swallows
384
+ # one after its own tag, a code does not - so textContent
385
+ # stays byte-identical to the bare-pre form.
386
+ text = f"<code>{text}\n</code>"
334
387
  if piece["part"] == "file":
335
388
  title = html.escape(piece["title"])
336
- return f'<pre{lit} title="{title}">\n{text}\n</pre>\n'
389
+ return f'<pre{lit} title="{title}">{text}</pre>\n'
337
390
  if piece["part"] in ("config", "body"):
338
391
  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"
392
+ return f'<pre{lit} data-role="{role}">{text}</pre>\n'
393
+ return f"<pre{lit}>{text}</pre>\n"
341
394
 
342
395
  def paint(self, code, piece):
343
396
  """
344
397
  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.
398
+ of any kind - render_piece adds the one code element both
399
+ paths share, and the pre stays the direct child. The
400
+ class="highlight" the coloured path adds is what lets a
401
+ Material or Zensical site's existing Pygments stylesheet
402
+ colour these blocks, palettes and all, with no CSS of ours.
350
403
 
351
404
  The language comes from the fence, or a file's name, with
352
405
  json and html assumed for config and body; an unknown or
@@ -385,7 +438,7 @@ class ImportPythonExtension(Extension):
385
438
  def __init__(self, **kwargs):
386
439
  self.config = {
387
440
  "script_url": [
388
- "https://importpython.org/bfp/test/embed.js",
441
+ "https://importpython.org/v/test/embed.js",
389
442
  "Where embed.js is served from; point this at "
390
443
  "your own copy to use one.",
391
444
  ],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: importpython-markdown
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Runnable Python examples in Markdown, via importpython.org.
5
5
  Author-email: "Nicholas H.Tollervey" <ntoll@ntoll.org>
6
6
  License: MIT
@@ -43,9 +43,11 @@ reader presses Run. Plain fences are the program; a fence with
43
43
  `title="name.py"` is a file delivered beside it; `config` and
44
44
  `body` fences carry BFP settings and page markup (escaped for
45
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.
46
+ and `terminal`, `debug`, `send-embedder-url`, `title="..."`,
47
+ `run-label="..."`, `stop-label="..."`, `theme="..."`,
48
+ `terminal-rows="..."`, `terminal-cols="..."`, `class` and `id`
49
+ pass through to the element - each documented at
50
+ [importpython.org](https://importpython.org/docs/writing-markdown/).
49
51
 
50
52
  Install and enable:
51
53
 
@@ -91,7 +91,7 @@ def test_close_gate_inside_inner_fence_is_content():
91
91
  "```\nprint('hi')\n```\n!#\n"
92
92
  )
93
93
  assert out.count("<import-python") == 1
94
- assert ">\n!#\n</pre>" in out
94
+ assert "<code>!#\n</code></pre>" in out
95
95
 
96
96
 
97
97
  def test_attributes_pass_through_escaped():
@@ -198,9 +198,9 @@ def test_script_tag_once_for_many_examples():
198
198
  configured one.
199
199
  """
200
200
  two = EXAMPLE + "\n" + EXAMPLE
201
- out = render(two, script_url="/bfp/test/embed.js")
201
+ out = render(two, script_url="/v/test/embed.js")
202
202
  assert out.count("<import-python") == 2
203
- assert out.count('src="/bfp/test/embed.js"') == 1
203
+ assert out.count('src="/v/test/embed.js"') == 1
204
204
  assert 'type="module"' in out
205
205
 
206
206
 
@@ -213,18 +213,28 @@ def test_script_injection_can_be_turned_off():
213
213
  assert "embed.js" not in out
214
214
 
215
215
 
216
- def test_highlighting_is_spans_in_a_bare_pre():
216
+ def test_highlighting_is_spans_in_a_pre_code_pair():
217
217
  """
218
- Intent: colour arrives as Pygments spans inside the bare pre
219
- direct child, marked class="highlight" so a site's existing
220
- Pygments stylesheet applies - never a wrapper element, which
221
- would hide the pre from the element.
218
+ Intent: every part is a pre holding one code element - the
219
+ shape MkDocs-family themes hang block styling and the copy
220
+ button on, and one the element reads identically to a bare
221
+ pre. Colour is Pygments spans inside the code, the pre
222
+ marked class="highlight" so a site's existing stylesheet
223
+ applies - never a wrapper outside the pre, which would hide
224
+ it from the element. With highlighting off the code element
225
+ remains, so theme styling never depends on Pygments.
222
226
  """
223
227
  out = render("#!mpy\n\n```python\nprint(1)\n```\n!#\n")
224
- assert '<pre class="highlight">' in out
228
+ assert '<pre class="highlight"><code>' in out
225
229
  assert '<span class="nb">print</span>' in out
230
+ assert "</code></pre>" in out
226
231
  assert "<div" not in out
227
- assert "<code" not in out
232
+ plain = render(
233
+ "#!mpy\n\n```python\nprint(1)\n```\n!#\n",
234
+ highlight=False,
235
+ )
236
+ assert "<pre><code>print(1)\n</code></pre>" in plain
237
+ assert "<span" not in plain
228
238
 
229
239
 
230
240
  def test_file_language_guessed_from_its_name():
@@ -272,3 +282,74 @@ def test_the_element_is_never_paragraph_wrapped():
272
282
  out = render(EXAMPLE)
273
283
  assert "<p><import-python" not in out
274
284
  assert "<p><script" not in out
285
+
286
+
287
+ def test_send_embedder_url_flag_passes_through():
288
+ """
289
+ Intent: the flag is allow-listed and reaches the element as
290
+ its attribute, so the page's embed.js can act on it.
291
+ """
292
+ out = render(
293
+ "#!mpy send-embedder-url\n\n```\nimport importpython\n```\n!#\n"
294
+ )
295
+ assert "send-embedder-url" in out.split(">")[0]
296
+
297
+
298
+ def test_send_embedder_url_reserves_the_filename():
299
+ """
300
+ Intent: while the flag is on, importpython.py belongs to the
301
+ service - a titled block or a configured files entry by that
302
+ name is a named build failure, never a silent overwrite.
303
+ """
304
+ with pytest.raises(ExampleError, match="reserves the file"):
305
+ render(
306
+ "#!mpy send-embedder-url\n\n"
307
+ '```title="importpython.py"\nx\n```\n\n'
308
+ "```\nimport importpython\n```\n!#\n"
309
+ )
310
+ with pytest.raises(ExampleError, match="reserves the file"):
311
+ render(
312
+ "#!mpy send-embedder-url\n\n"
313
+ "```config\n"
314
+ '{ "files": { "data:x": "./importpython.py" } }\n'
315
+ "```\n\n```\nimport importpython\n```\n!#\n"
316
+ )
317
+ # Without the flag, the name is the author's to use.
318
+ out = render(
319
+ '#!mpy\n\n```title="importpython.py"\nx = 1\n```\n\n'
320
+ "```\nimport importpython\n```\n!#\n"
321
+ )
322
+ assert 'title="importpython.py"' in out
323
+
324
+
325
+ def test_terminal_size_passes_through():
326
+ """
327
+ Intent: terminal-rows and terminal-cols are allow-listed valued
328
+ attributes that reach the element, sized and named as BFP
329
+ has them.
330
+ """
331
+ out = render(
332
+ '#!mpy terminal terminal-rows="4" terminal-cols="60"\n\n'
333
+ "```\nprint(1)\n```\n!#\n"
334
+ )
335
+ assert 'terminal-rows="4"' in out
336
+ assert 'terminal-cols="60"' in out
337
+
338
+
339
+ def test_terminal_size_is_validated_at_build_time():
340
+ """
341
+ Intent: a dimension without the terminal flag, or a value
342
+ that is not a positive whole number, fails the build with
343
+ the element's own words - never a runtime error in a
344
+ reader's browser.
345
+ """
346
+ with pytest.raises(ExampleError, match="requires the terminal"):
347
+ render('#!mpy terminal-rows="4"\n\n```\nx\n```\n!#\n')
348
+ with pytest.raises(ExampleError, match="positive whole"):
349
+ render(
350
+ '#!mpy terminal terminal-rows="0"\n\n```\nx\n```\n!#\n'
351
+ )
352
+ with pytest.raises(ExampleError, match="positive whole"):
353
+ render(
354
+ '#!mpy terminal terminal-cols="wide"\n\n```\nx\n```\n!#\n'
355
+ )