pylearnspec 0.4.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.
Files changed (49) hide show
  1. pylearnspec/__init__.py +50 -0
  2. pylearnspec/badgemd/__init__.py +6 -0
  3. pylearnspec/badgemd/models.py +28 -0
  4. pylearnspec/badgemd/parser.py +67 -0
  5. pylearnspec/badgemd/validator.py +59 -0
  6. pylearnspec/certmd/__init__.py +6 -0
  7. pylearnspec/certmd/models.py +28 -0
  8. pylearnspec/certmd/parser.py +65 -0
  9. pylearnspec/certmd/validator.py +72 -0
  10. pylearnspec/common/__init__.py +0 -0
  11. pylearnspec/common/directives.py +130 -0
  12. pylearnspec/common/frontmatter.py +32 -0
  13. pylearnspec/common/validation.py +22 -0
  14. pylearnspec/diagrammd/__init__.py +6 -0
  15. pylearnspec/diagrammd/models.py +43 -0
  16. pylearnspec/diagrammd/parser.py +94 -0
  17. pylearnspec/diagrammd/validator.py +42 -0
  18. pylearnspec/flashmd/__init__.py +6 -0
  19. pylearnspec/flashmd/models.py +42 -0
  20. pylearnspec/flashmd/parser.py +74 -0
  21. pylearnspec/flashmd/validator.py +56 -0
  22. pylearnspec/glossarymd/__init__.py +6 -0
  23. pylearnspec/glossarymd/models.py +42 -0
  24. pylearnspec/glossarymd/parser.py +109 -0
  25. pylearnspec/glossarymd/validator.py +51 -0
  26. pylearnspec/learnmd/__init__.py +4 -0
  27. pylearnspec/learnmd/models.py +84 -0
  28. pylearnspec/learnmd/parser.py +156 -0
  29. pylearnspec/learnmd/validator.py +79 -0
  30. pylearnspec/mediamd/__init__.py +6 -0
  31. pylearnspec/mediamd/models.py +35 -0
  32. pylearnspec/mediamd/parser.py +74 -0
  33. pylearnspec/mediamd/validator.py +62 -0
  34. pylearnspec/nuggetmd/__init__.py +6 -0
  35. pylearnspec/nuggetmd/models.py +135 -0
  36. pylearnspec/nuggetmd/parser.py +272 -0
  37. pylearnspec/nuggetmd/validator.py +170 -0
  38. pylearnspec/quizmd/__init__.py +4 -0
  39. pylearnspec/quizmd/models.py +80 -0
  40. pylearnspec/quizmd/parser.py +277 -0
  41. pylearnspec/quizmd/validator.py +82 -0
  42. pylearnspec/trackmd/__init__.py +6 -0
  43. pylearnspec/trackmd/models.py +40 -0
  44. pylearnspec/trackmd/parser.py +66 -0
  45. pylearnspec/trackmd/validator.py +64 -0
  46. pylearnspec-0.4.0.dist-info/METADATA +144 -0
  47. pylearnspec-0.4.0.dist-info/RECORD +49 -0
  48. pylearnspec-0.4.0.dist-info/WHEEL +5 -0
  49. pylearnspec-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,50 @@
1
+ """pylearnspec — Python parser and validator for the LearnSpec suite.
2
+
3
+ Supported formats:
4
+ - LearnMD (parse_learn, validate_learn)
5
+ - QuizMD (parse_quiz, validate_quiz)
6
+ - FlashMD (parse_flash, validate_flash)
7
+ - TrackMD (parse_track, validate_track)
8
+ - DiagramMD (parse_diagram, validate_diagram)
9
+ - MediaMD (parse_media, validate_media)
10
+ - GlossaryMD (parse_glossary, validate_glossary)
11
+ - BadgeMD (parse_badge, validate_badge)
12
+ - CertMD (parse_cert, validate_cert)
13
+ - NuggetMD (parse_nugget, validate_nugget)
14
+ """
15
+
16
+ __version__ = "0.4.0"
17
+
18
+ from pylearnspec.badgemd.parser import parse_badge
19
+ from pylearnspec.badgemd.validator import validate_badge
20
+ from pylearnspec.certmd.parser import parse_cert
21
+ from pylearnspec.certmd.validator import validate_cert
22
+ from pylearnspec.diagrammd.parser import parse_diagram
23
+ from pylearnspec.diagrammd.validator import validate_diagram
24
+ from pylearnspec.flashmd.parser import parse_flash
25
+ from pylearnspec.flashmd.validator import validate_flash
26
+ from pylearnspec.glossarymd.parser import parse_glossary
27
+ from pylearnspec.glossarymd.validator import validate_glossary
28
+ from pylearnspec.learnmd.parser import parse_learn
29
+ from pylearnspec.learnmd.validator import validate_learn
30
+ from pylearnspec.mediamd.parser import parse_media
31
+ from pylearnspec.mediamd.validator import validate_media
32
+ from pylearnspec.nuggetmd.parser import parse_nugget
33
+ from pylearnspec.nuggetmd.validator import validate_nugget
34
+ from pylearnspec.quizmd.parser import parse_quiz
35
+ from pylearnspec.quizmd.validator import validate_quiz
36
+ from pylearnspec.trackmd.parser import parse_track
37
+ from pylearnspec.trackmd.validator import validate_track
38
+
39
+ __all__ = [
40
+ "parse_quiz", "validate_quiz",
41
+ "parse_learn", "validate_learn",
42
+ "parse_flash", "validate_flash",
43
+ "parse_track", "validate_track",
44
+ "parse_diagram", "validate_diagram",
45
+ "parse_media", "validate_media",
46
+ "parse_glossary", "validate_glossary",
47
+ "parse_badge", "validate_badge",
48
+ "parse_cert", "validate_cert",
49
+ "parse_nugget", "validate_nugget",
50
+ ]
@@ -0,0 +1,6 @@
1
+ """BadgeMD — micro-credentials format for the LearnSpec suite."""
2
+
3
+ from pylearnspec.badgemd.parser import parse_badge
4
+ from pylearnspec.badgemd.validator import validate_badge
5
+
6
+ __all__ = ["parse_badge", "validate_badge"]
@@ -0,0 +1,28 @@
1
+ """Data models for BadgeMD parsed output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Badge:
11
+ title: str = ""
12
+ level: str = "0"
13
+ frontmatter: dict[str, Any] = field(default_factory=dict)
14
+ image_path: str = "" # from the ![](...) line in the body
15
+ body_text: str = "" # narrative criteria
16
+ criteria: list[dict[str, Any]] = field(default_factory=list)
17
+ has_criteria_block: bool = False
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ return {
21
+ "title": self.title,
22
+ "level": self.level,
23
+ "frontmatter": self.frontmatter,
24
+ "image_path": self.image_path,
25
+ "body_text": self.body_text,
26
+ "criteria": self.criteria,
27
+ "has_criteria_block": self.has_criteria_block,
28
+ }
@@ -0,0 +1,67 @@
1
+ """BadgeMD parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import yaml
8
+
9
+ from pylearnspec.badgemd.models import Badge
10
+ from pylearnspec.common.frontmatter import extract_frontmatter
11
+
12
+ _H1_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
13
+ _IMAGE_LINE_RE = re.compile(r"^!\[[^\]]*\]\(([^)]+)\)", re.MULTILINE)
14
+ _CRITERIA_BLOCK_RE = re.compile(
15
+ r"^```criteria\s*\n(.*?)^```\s*$",
16
+ re.MULTILINE | re.DOTALL,
17
+ )
18
+
19
+
20
+ def parse_badge(content: str) -> Badge:
21
+ frontmatter, body = extract_frontmatter(content)
22
+
23
+ title = frontmatter.get("name", "") or frontmatter.get("title", "")
24
+ if not title:
25
+ m = _H1_RE.search(body)
26
+ if m:
27
+ title = m.group(1).strip()
28
+
29
+ image_path = ""
30
+ if (m := _IMAGE_LINE_RE.search(body)):
31
+ image_path = m.group(1).strip()
32
+
33
+ criteria: list[dict] = []
34
+ has_criteria_block = False
35
+ if (m := _CRITERIA_BLOCK_RE.search(body)):
36
+ has_criteria_block = True
37
+ try:
38
+ loaded = yaml.safe_load(m.group(1))
39
+ if isinstance(loaded, dict):
40
+ criteria = [loaded]
41
+ elif isinstance(loaded, list):
42
+ criteria = [c for c in loaded if isinstance(c, dict)]
43
+ except yaml.YAMLError:
44
+ pass
45
+
46
+ # Strip image and criteria block from body to get narrative text
47
+ body_text = _CRITERIA_BLOCK_RE.sub("", body)
48
+ body_text = _IMAGE_LINE_RE.sub("", body_text, count=1)
49
+ body_text = body_text.strip()
50
+
51
+ has_fm = bool(frontmatter)
52
+ if has_criteria_block:
53
+ level = "2"
54
+ elif has_fm:
55
+ level = "1"
56
+ else:
57
+ level = "0"
58
+
59
+ return Badge(
60
+ title=title,
61
+ level=level,
62
+ frontmatter=frontmatter,
63
+ image_path=image_path,
64
+ body_text=body_text,
65
+ criteria=criteria,
66
+ has_criteria_block=has_criteria_block,
67
+ )
@@ -0,0 +1,59 @@
1
+ """BadgeMD validator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from pylearnspec.badgemd.parser import parse_badge
8
+ from pylearnspec.common.validation import Diagnostic, severity_for
9
+
10
+ _DURATION_RE = re.compile(r"^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$")
11
+
12
+ KNOWN_CRITERIA_TYPES = {"track_complete", "quiz_pass", "checkpoint_reached", "manual"}
13
+
14
+
15
+ def validate_badge(content: str, *, strict: bool = False) -> list[Diagnostic]:
16
+ diags: list[Diagnostic] = []
17
+ try:
18
+ b = parse_badge(content)
19
+ except Exception as e:
20
+ diags.append(Diagnostic(level="error", message=f"Parse error: {e}"))
21
+ return diags
22
+
23
+ sev = severity_for(strict)
24
+ fm = b.frontmatter
25
+
26
+ if not fm.get("lang"):
27
+ diags.append(Diagnostic(level=sev, message="Missing 'lang' in frontmatter"))
28
+ if not fm.get("name"):
29
+ diags.append(Diagnostic(level="error", message="Missing required 'name'"))
30
+ if not fm.get("image"):
31
+ diags.append(Diagnostic(level="error", message="Missing required 'image'"))
32
+ elif not str(fm["image"]).lower().endswith(".svg"):
33
+ diags.append(Diagnostic(level=sev, message="'image' should point to an SVG file"))
34
+
35
+ issuer = fm.get("issuer") or {}
36
+ if not isinstance(issuer, dict) or not issuer.get("name"):
37
+ diags.append(Diagnostic(level="error", message="Missing required 'issuer.name'"))
38
+ if isinstance(issuer, dict) and not issuer.get("url"):
39
+ diags.append(Diagnostic(level=sev, message="Missing 'issuer.url'"))
40
+
41
+ if not b.image_path:
42
+ diags.append(Diagnostic(level=sev, message="No Markdown image line found in the body"))
43
+
44
+ if not b.has_criteria_block:
45
+ diags.append(Diagnostic(level=sev, message="No 'criteria' fenced block — award conditions are narrative only"))
46
+ else:
47
+ for i, c in enumerate(b.criteria, start=1):
48
+ t = c.get("type")
49
+ if t and t not in KNOWN_CRITERIA_TYPES:
50
+ diags.append(Diagnostic(level=sev, message=f"Criterion #{i}: unrecognised type '{t}'"))
51
+
52
+ expires = fm.get("expires")
53
+ if expires is not None and not _DURATION_RE.match(str(expires)):
54
+ diags.append(Diagnostic(
55
+ level="error",
56
+ message=f"'expires' must be an ISO 8601 duration (got {expires!r})",
57
+ ))
58
+
59
+ return diags
@@ -0,0 +1,6 @@
1
+ """CertMD — macro-credentials format for the LearnSpec suite."""
2
+
3
+ from pylearnspec.certmd.parser import parse_cert
4
+ from pylearnspec.certmd.validator import validate_cert
5
+
6
+ __all__ = ["parse_cert", "validate_cert"]
@@ -0,0 +1,28 @@
1
+ """Data models for CertMD parsed output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Cert:
11
+ title: str = ""
12
+ level: str = "0"
13
+ frontmatter: dict[str, Any] = field(default_factory=dict)
14
+ image_path: str = ""
15
+ body_text: str = ""
16
+ requirements: list[dict[str, Any]] = field(default_factory=list)
17
+ has_requirements_block: bool = False
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ return {
21
+ "title": self.title,
22
+ "level": self.level,
23
+ "frontmatter": self.frontmatter,
24
+ "image_path": self.image_path,
25
+ "body_text": self.body_text,
26
+ "requirements": self.requirements,
27
+ "has_requirements_block": self.has_requirements_block,
28
+ }
@@ -0,0 +1,65 @@
1
+ """CertMD parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import yaml
8
+
9
+ from pylearnspec.certmd.models import Cert
10
+ from pylearnspec.common.frontmatter import extract_frontmatter
11
+
12
+ _H1_RE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
13
+ _IMAGE_LINE_RE = re.compile(r"^!\[[^\]]*\]\(([^)]+)\)", re.MULTILINE)
14
+ _REQUIREMENTS_BLOCK_RE = re.compile(
15
+ r"^```requirements\s*\n(.*?)^```\s*$",
16
+ re.MULTILINE | re.DOTALL,
17
+ )
18
+
19
+
20
+ def parse_cert(content: str) -> Cert:
21
+ frontmatter, body = extract_frontmatter(content)
22
+
23
+ title = frontmatter.get("name", "") or frontmatter.get("title", "")
24
+ if not title:
25
+ m = _H1_RE.search(body)
26
+ if m:
27
+ title = m.group(1).strip()
28
+
29
+ image_path = ""
30
+ if (m := _IMAGE_LINE_RE.search(body)):
31
+ image_path = m.group(1).strip()
32
+
33
+ requirements: list[dict] = []
34
+ has_block = False
35
+ if (m := _REQUIREMENTS_BLOCK_RE.search(body)):
36
+ has_block = True
37
+ try:
38
+ loaded = yaml.safe_load(m.group(1))
39
+ if isinstance(loaded, dict):
40
+ requirements = [loaded]
41
+ elif isinstance(loaded, list):
42
+ requirements = [r for r in loaded if isinstance(r, dict)]
43
+ except yaml.YAMLError:
44
+ pass
45
+
46
+ body_text = _REQUIREMENTS_BLOCK_RE.sub("", body)
47
+ body_text = _IMAGE_LINE_RE.sub("", body_text, count=1).strip()
48
+
49
+ has_fm = bool(frontmatter)
50
+ if has_block:
51
+ level = "2"
52
+ elif has_fm:
53
+ level = "1"
54
+ else:
55
+ level = "0"
56
+
57
+ return Cert(
58
+ title=title,
59
+ level=level,
60
+ frontmatter=frontmatter,
61
+ image_path=image_path,
62
+ body_text=body_text,
63
+ requirements=requirements,
64
+ has_requirements_block=has_block,
65
+ )
@@ -0,0 +1,72 @@
1
+ """CertMD validator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from pylearnspec.certmd.parser import parse_cert
8
+ from pylearnspec.common.validation import Diagnostic, severity_for
9
+
10
+ _DURATION_RE = re.compile(r"^P(?:\d+Y)?(?:\d+M)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$")
11
+
12
+
13
+ def validate_cert(content: str, *, strict: bool = False) -> list[Diagnostic]:
14
+ diags: list[Diagnostic] = []
15
+ try:
16
+ c = parse_cert(content)
17
+ except Exception as e:
18
+ diags.append(Diagnostic(level="error", message=f"Parse error: {e}"))
19
+ return diags
20
+
21
+ sev = severity_for(strict)
22
+ fm = c.frontmatter
23
+
24
+ if not fm.get("lang"):
25
+ diags.append(Diagnostic(level=sev, message="Missing 'lang' in frontmatter"))
26
+ if not fm.get("name"):
27
+ diags.append(Diagnostic(level="error", message="Missing required 'name'"))
28
+ if not fm.get("image"):
29
+ diags.append(Diagnostic(level="error", message="Missing required 'image'"))
30
+ elif not str(fm["image"]).lower().endswith(".svg"):
31
+ diags.append(Diagnostic(level=sev, message="'image' should point to an SVG file"))
32
+
33
+ issuer = fm.get("issuer") or {}
34
+ if not isinstance(issuer, dict) or not issuer.get("name"):
35
+ diags.append(Diagnostic(level="error", message="Missing required 'issuer.name'"))
36
+
37
+ # grade_levels sanity
38
+ grades = fm.get("grade_levels") or {}
39
+ if isinstance(grades, dict):
40
+ previous = -1.0
41
+ for key in ("pass", "merit", "distinction"):
42
+ if key in grades:
43
+ try:
44
+ v = float(grades[key])
45
+ except (TypeError, ValueError):
46
+ diags.append(Diagnostic(level="error", message=f"grade_levels.{key} must be numeric"))
47
+ continue
48
+ if v < 0 or v > 1:
49
+ diags.append(Diagnostic(
50
+ level="error",
51
+ message=f"grade_levels.{key} must be between 0.0 and 1.0",
52
+ ))
53
+ if v < previous:
54
+ diags.append(Diagnostic(
55
+ level=sev,
56
+ message=f"grade_levels.{key} ({v}) is below the previous threshold ({previous})",
57
+ ))
58
+ previous = v
59
+
60
+ if not c.image_path:
61
+ diags.append(Diagnostic(level=sev, message="No Markdown image line found in the body"))
62
+ if not c.has_requirements_block:
63
+ diags.append(Diagnostic(level=sev, message="No 'requirements' block — award conditions are narrative only"))
64
+
65
+ expires = fm.get("expires")
66
+ if expires is not None and not _DURATION_RE.match(str(expires)):
67
+ diags.append(Diagnostic(
68
+ level="error",
69
+ message=f"'expires' must be an ISO 8601 duration (got {expires!r})",
70
+ ))
71
+
72
+ return diags
File without changes
@@ -0,0 +1,130 @@
1
+ """Cross-format directive and reference extraction.
2
+
3
+ These directives are part of the LearnSpec Architecture Charter and shared
4
+ across all content formats:
5
+
6
+ - ``!import <path>`` — composition (inline another file)
7
+ - ``!ref <path>`` — context (declare a dependency without inline render)
8
+ - ``!checkpoint id:...`` — named progress marker
9
+ - ``media:slug`` — symbolic media reference resolved via MediaMD
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ _IMPORT_RE = re.compile(r"^!import[ \t]+(\S+)(?:[ \t]+([^\n]*))?$", re.MULTILINE)
19
+ _REF_RE = re.compile(r"^!ref[ \t]+(\S+)[ \t]*$", re.MULTILINE)
20
+ _CHECKPOINT_RE = re.compile(r"^!checkpoint[ \t]+([^\n]+)$", re.MULTILINE)
21
+ _MEDIA_SLUG_RE = re.compile(
22
+ r'!\[[^\]]*\]\(media:([a-z0-9][a-z0-9-]*)(?:\s+"([^"]*)")?\)'
23
+ )
24
+
25
+ # Attributes inside a directive line: key:value, key:"quoted value", key:true
26
+ _ATTR_RE = re.compile(r'(\w+):(?:"([^"]*)"|(\S+))')
27
+
28
+
29
+ def _parse_attrs(text: str) -> dict[str, Any]:
30
+ """Parse `key:value` and `key:"quoted value"` attribute pairs from a string."""
31
+ out: dict[str, Any] = {}
32
+ for m in _ATTR_RE.finditer(text):
33
+ key = m.group(1)
34
+ value = m.group(2) if m.group(2) is not None else m.group(3)
35
+ if value in ("true", "false"):
36
+ out[key] = value == "true"
37
+ else:
38
+ out[key] = value
39
+ return out
40
+
41
+
42
+ @dataclass
43
+ class ImportDirective:
44
+ path: str
45
+ attrs: dict[str, Any] = field(default_factory=dict)
46
+
47
+ @property
48
+ def kind(self) -> str:
49
+ """Return the imported file kind: learn, quiz, flash, diagram, or unknown."""
50
+ for ext in ("learn", "quiz", "flash", "diagram", "track"):
51
+ if self.path.endswith(f".{ext}.md"):
52
+ return ext
53
+ return "unknown"
54
+
55
+ def to_dict(self) -> dict[str, Any]:
56
+ d: dict[str, Any] = {"path": self.path, "kind": self.kind}
57
+ if self.attrs:
58
+ d["attrs"] = self.attrs
59
+ return d
60
+
61
+
62
+ @dataclass
63
+ class RefDirective:
64
+ path: str
65
+
66
+ @property
67
+ def kind(self) -> str:
68
+ for ext in ("media", "glossary"):
69
+ if self.path.endswith(f".{ext}.md"):
70
+ return ext
71
+ return "unknown"
72
+
73
+ def to_dict(self) -> dict[str, Any]:
74
+ return {"path": self.path, "kind": self.kind}
75
+
76
+
77
+ @dataclass
78
+ class Checkpoint:
79
+ id: str
80
+ label: str = ""
81
+ type: str = "milestone"
82
+ badge: str = ""
83
+
84
+ def to_dict(self) -> dict[str, Any]:
85
+ return {"id": self.id, "label": self.label, "type": self.type, "badge": self.badge}
86
+
87
+
88
+ @dataclass
89
+ class MediaRef:
90
+ """Inline ``media:slug`` image reference."""
91
+ slug: str
92
+ fallback_url: str = ""
93
+
94
+ def to_dict(self) -> dict[str, Any]:
95
+ return {"slug": self.slug, "fallback_url": self.fallback_url}
96
+
97
+
98
+ def extract_imports(text: str) -> list[ImportDirective]:
99
+ out: list[ImportDirective] = []
100
+ for m in _IMPORT_RE.finditer(text):
101
+ path = m.group(1).strip()
102
+ attrs_text = (m.group(2) or "").strip()
103
+ out.append(ImportDirective(path=path, attrs=_parse_attrs(attrs_text)))
104
+ return out
105
+
106
+
107
+ def extract_refs(text: str) -> list[RefDirective]:
108
+ return [RefDirective(path=m.group(1).strip()) for m in _REF_RE.finditer(text)]
109
+
110
+
111
+ def extract_checkpoints(text: str) -> list[Checkpoint]:
112
+ out: list[Checkpoint] = []
113
+ for m in _CHECKPOINT_RE.finditer(text):
114
+ attrs = _parse_attrs(m.group(1))
115
+ if "id" not in attrs:
116
+ continue
117
+ out.append(Checkpoint(
118
+ id=str(attrs["id"]),
119
+ label=str(attrs.get("label", "")),
120
+ type=str(attrs.get("type", "milestone")),
121
+ badge=str(attrs.get("badge", "")),
122
+ ))
123
+ return out
124
+
125
+
126
+ def extract_media_refs(text: str) -> list[MediaRef]:
127
+ return [
128
+ MediaRef(slug=m.group(1), fallback_url=m.group(2) or "")
129
+ for m in _MEDIA_SLUG_RE.finditer(text)
130
+ ]
@@ -0,0 +1,32 @@
1
+ """YAML frontmatter extraction shared by QuizMD and LearnMD parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ _FRONTMATTER_RE = re.compile(
11
+ r"\A\s*---[ \t]*\n(.*?\n)---[ \t]*\n",
12
+ re.DOTALL,
13
+ )
14
+
15
+
16
+ def extract_frontmatter(text: str) -> tuple[dict[str, Any], str]:
17
+ """Return (frontmatter_dict, remaining_text).
18
+
19
+ If no frontmatter is found, returns ({}, text).
20
+ """
21
+ m = _FRONTMATTER_RE.match(text)
22
+ if not m:
23
+ return {}, text
24
+ raw = m.group(1)
25
+ try:
26
+ data = yaml.safe_load(raw)
27
+ except yaml.YAMLError:
28
+ return {}, text
29
+ if not isinstance(data, dict):
30
+ return {}, text
31
+ rest = text[m.end() :]
32
+ return data, rest
@@ -0,0 +1,22 @@
1
+ """Shared validation primitives used across all LearnSpec formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ Severity = Literal["error", "warning"]
9
+
10
+
11
+ @dataclass
12
+ class Diagnostic:
13
+ level: Severity
14
+ message: str
15
+
16
+ def to_dict(self) -> dict:
17
+ return {"level": self.level, "message": self.message}
18
+
19
+
20
+ def severity_for(strict: bool) -> Severity:
21
+ """Return the severity used for fields demoted to warning in lenient mode."""
22
+ return "error" if strict else "warning"
@@ -0,0 +1,6 @@
1
+ """DiagramMD — canonical diagram syntax for the LearnSpec suite."""
2
+
3
+ from pylearnspec.diagrammd.parser import parse_diagram
4
+ from pylearnspec.diagrammd.validator import validate_diagram
5
+
6
+ __all__ = ["parse_diagram", "validate_diagram"]
@@ -0,0 +1,43 @@
1
+ """Data models for DiagramMD parsed output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class Diagram:
11
+ type: str # mermaid, tikz, graphviz, plantuml, blockdiag, seqdiag, chess, abc, smiles, vega-lite, …
12
+ source: str = ""
13
+ id: str = ""
14
+ caption: str = ""
15
+ alt: str = ""
16
+ width: str = ""
17
+ extra: dict[str, Any] = field(default_factory=dict) # e.g. play, cursor, colors for abc
18
+
19
+ def to_dict(self) -> dict[str, Any]:
20
+ d: dict[str, Any] = {"type": self.type, "source": self.source}
21
+ for k in ("id", "caption", "alt", "width"):
22
+ v = getattr(self, k)
23
+ if v:
24
+ d[k] = v
25
+ if self.extra:
26
+ d["extra"] = self.extra
27
+ return d
28
+
29
+
30
+ @dataclass
31
+ class DiagramFile:
32
+ title: str = ""
33
+ level: str = "0"
34
+ frontmatter: dict[str, Any] = field(default_factory=dict)
35
+ diagrams: list[Diagram] = field(default_factory=list)
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ return {
39
+ "title": self.title,
40
+ "level": self.level,
41
+ "frontmatter": self.frontmatter,
42
+ "diagrams": [d.to_dict() for d in self.diagrams],
43
+ }