pylearnspec 0.4.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 (63) hide show
  1. pylearnspec-0.4.0/.gitignore +15 -0
  2. pylearnspec-0.4.0/PKG-INFO +144 -0
  3. pylearnspec-0.4.0/README.md +118 -0
  4. pylearnspec-0.4.0/pyproject.toml +45 -0
  5. pylearnspec-0.4.0/setup.cfg +4 -0
  6. pylearnspec-0.4.0/src/pylearnspec/__init__.py +50 -0
  7. pylearnspec-0.4.0/src/pylearnspec/badgemd/__init__.py +6 -0
  8. pylearnspec-0.4.0/src/pylearnspec/badgemd/models.py +28 -0
  9. pylearnspec-0.4.0/src/pylearnspec/badgemd/parser.py +67 -0
  10. pylearnspec-0.4.0/src/pylearnspec/badgemd/validator.py +59 -0
  11. pylearnspec-0.4.0/src/pylearnspec/certmd/__init__.py +6 -0
  12. pylearnspec-0.4.0/src/pylearnspec/certmd/models.py +28 -0
  13. pylearnspec-0.4.0/src/pylearnspec/certmd/parser.py +65 -0
  14. pylearnspec-0.4.0/src/pylearnspec/certmd/validator.py +72 -0
  15. pylearnspec-0.4.0/src/pylearnspec/common/__init__.py +0 -0
  16. pylearnspec-0.4.0/src/pylearnspec/common/directives.py +130 -0
  17. pylearnspec-0.4.0/src/pylearnspec/common/frontmatter.py +32 -0
  18. pylearnspec-0.4.0/src/pylearnspec/common/validation.py +22 -0
  19. pylearnspec-0.4.0/src/pylearnspec/diagrammd/__init__.py +6 -0
  20. pylearnspec-0.4.0/src/pylearnspec/diagrammd/models.py +43 -0
  21. pylearnspec-0.4.0/src/pylearnspec/diagrammd/parser.py +94 -0
  22. pylearnspec-0.4.0/src/pylearnspec/diagrammd/validator.py +42 -0
  23. pylearnspec-0.4.0/src/pylearnspec/flashmd/__init__.py +6 -0
  24. pylearnspec-0.4.0/src/pylearnspec/flashmd/models.py +42 -0
  25. pylearnspec-0.4.0/src/pylearnspec/flashmd/parser.py +74 -0
  26. pylearnspec-0.4.0/src/pylearnspec/flashmd/validator.py +56 -0
  27. pylearnspec-0.4.0/src/pylearnspec/glossarymd/__init__.py +6 -0
  28. pylearnspec-0.4.0/src/pylearnspec/glossarymd/models.py +42 -0
  29. pylearnspec-0.4.0/src/pylearnspec/glossarymd/parser.py +109 -0
  30. pylearnspec-0.4.0/src/pylearnspec/glossarymd/validator.py +51 -0
  31. pylearnspec-0.4.0/src/pylearnspec/learnmd/__init__.py +4 -0
  32. pylearnspec-0.4.0/src/pylearnspec/learnmd/models.py +84 -0
  33. pylearnspec-0.4.0/src/pylearnspec/learnmd/parser.py +156 -0
  34. pylearnspec-0.4.0/src/pylearnspec/learnmd/validator.py +79 -0
  35. pylearnspec-0.4.0/src/pylearnspec/mediamd/__init__.py +6 -0
  36. pylearnspec-0.4.0/src/pylearnspec/mediamd/models.py +35 -0
  37. pylearnspec-0.4.0/src/pylearnspec/mediamd/parser.py +74 -0
  38. pylearnspec-0.4.0/src/pylearnspec/mediamd/validator.py +62 -0
  39. pylearnspec-0.4.0/src/pylearnspec/nuggetmd/__init__.py +6 -0
  40. pylearnspec-0.4.0/src/pylearnspec/nuggetmd/models.py +135 -0
  41. pylearnspec-0.4.0/src/pylearnspec/nuggetmd/parser.py +272 -0
  42. pylearnspec-0.4.0/src/pylearnspec/nuggetmd/validator.py +170 -0
  43. pylearnspec-0.4.0/src/pylearnspec/quizmd/__init__.py +4 -0
  44. pylearnspec-0.4.0/src/pylearnspec/quizmd/models.py +80 -0
  45. pylearnspec-0.4.0/src/pylearnspec/quizmd/parser.py +277 -0
  46. pylearnspec-0.4.0/src/pylearnspec/quizmd/validator.py +82 -0
  47. pylearnspec-0.4.0/src/pylearnspec/trackmd/__init__.py +6 -0
  48. pylearnspec-0.4.0/src/pylearnspec/trackmd/models.py +40 -0
  49. pylearnspec-0.4.0/src/pylearnspec/trackmd/parser.py +66 -0
  50. pylearnspec-0.4.0/src/pylearnspec/trackmd/validator.py +64 -0
  51. pylearnspec-0.4.0/src/pylearnspec.egg-info/PKG-INFO +144 -0
  52. pylearnspec-0.4.0/src/pylearnspec.egg-info/SOURCES.txt +61 -0
  53. pylearnspec-0.4.0/src/pylearnspec.egg-info/dependency_links.txt +1 -0
  54. pylearnspec-0.4.0/src/pylearnspec.egg-info/requires.txt +5 -0
  55. pylearnspec-0.4.0/src/pylearnspec.egg-info/top_level.txt +1 -0
  56. pylearnspec-0.4.0/tests/__init__.py +0 -0
  57. pylearnspec-0.4.0/tests/fixtures/sample.learn.md +48 -0
  58. pylearnspec-0.4.0/tests/fixtures/sample.quiz.md +79 -0
  59. pylearnspec-0.4.0/tests/test_learnmd.py +100 -0
  60. pylearnspec-0.4.0/tests/test_nugget_smoke.py +258 -0
  61. pylearnspec-0.4.0/tests/test_quizmd.py +177 -0
  62. pylearnspec-0.4.0/tests/test_suite_smoke.py +323 -0
  63. pylearnspec-0.4.0/tests/test_v04_v03.py +141 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .pytest_cache/
9
+ .venv/
10
+ venv/
11
+ uv.lock
12
+ .env
13
+ *.so
14
+ .coverage
15
+ htmlcov/
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.2
2
+ Name: pylearnspec
3
+ Version: 0.4.0
4
+ Summary: Python parser and validator for the LearnSpec suite (LearnMD, QuizMD, FlashMD, TrackMD, DiagramMD, MediaMD, GlossaryMD, BadgeMD, CertMD, NuggetMD)
5
+ Author-email: learnspec <hello@learnspec.org>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/learnspec/pylearnspec
8
+ Project-URL: Repository, https://github.com/learnspec/pylearnspec
9
+ Keywords: learnspec,quizmd,learnmd,flashmd,trackmd,diagrammd,mediamd,glossarymd,badgemd,certmd,nuggetmd,parser,education,assessment
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Education
19
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: pyyaml>=6.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
26
+
27
+ # pylearnspec
28
+
29
+ Python parser and validator for the [LearnSpec](https://learnspec.org) suite of open standards:
30
+
31
+ | Format | API |
32
+ |---|---|
33
+ | **LearnMD** — instructional content | `parse_learn`, `validate_learn` |
34
+ | **QuizMD** — assessments | `parse_quiz`, `validate_quiz` |
35
+ | **FlashMD** — flashcards | `parse_flash`, `validate_flash` |
36
+ | **NuggetMD** — micro-learning nuggets | `parse_nugget`, `validate_nugget` |
37
+ | **TrackMD** — learning paths | `parse_track`, `validate_track` |
38
+ | **DiagramMD** — diagrams | `parse_diagram`, `validate_diagram` |
39
+ | **MediaMD** — media catalogues | `parse_media`, `validate_media` |
40
+ | **GlossaryMD** — glossaries | `parse_glossary`, `validate_glossary` |
41
+ | **BadgeMD** — micro-credentials | `parse_badge`, `validate_badge` |
42
+ | **CertMD** — macro-credentials | `parse_cert`, `validate_cert` |
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ pip install pylearnspec
48
+ ```
49
+
50
+ ## Quick start
51
+
52
+ ### Parse a QuizMD file
53
+
54
+ ```python
55
+ from pylearnspec import parse_quiz
56
+
57
+ with open("my-quiz.quiz.md") as f:
58
+ quiz = parse_quiz(f.read())
59
+
60
+ print(quiz.title)
61
+ print(f"{len(quiz.questions)} questions")
62
+
63
+ for q in quiz.questions:
64
+ print(f" Q{q.number} ({q.q_type}): {q.title}")
65
+ ```
66
+
67
+ ### Parse a LearnMD file
68
+
69
+ ```python
70
+ from pylearnspec import parse_learn
71
+
72
+ with open("my-lesson.learn.md") as f:
73
+ lesson = parse_learn(f.read())
74
+
75
+ print(lesson.title)
76
+ for section in lesson.sections:
77
+ print(f" {'#' * section.depth} {section.title}")
78
+ ```
79
+
80
+ ### Validate
81
+
82
+ ```python
83
+ from pylearnspec import validate_quiz, validate_learn
84
+
85
+ # Lenient mode (default)
86
+ diagnostics = validate_quiz(content)
87
+
88
+ # Strict mode (for CI)
89
+ diagnostics = validate_quiz(content, strict=True)
90
+
91
+ for d in diagnostics:
92
+ print(f"[{d.level}] {d.message}")
93
+ ```
94
+
95
+ ### JSON output
96
+
97
+ Both `Quiz` and `Lesson` objects have a `.to_dict()` method for JSON serialization:
98
+
99
+ ```python
100
+ import json
101
+ from pylearnspec import parse_quiz
102
+
103
+ quiz = parse_quiz(content)
104
+ print(json.dumps(quiz.to_dict(), indent=2))
105
+ ```
106
+
107
+ ## Supported features
108
+
109
+ ### QuizMD
110
+
111
+ - Frontmatter (Level 1) and per-question `quiz` blocks (Level 2)
112
+ - Question types: MCQ, multi-select, true/false, open answer, match, order
113
+ - Per-choice and global feedback (`[!correct]`, `[!incorrect]`)
114
+ - `!import` directives
115
+ - Validation (lenient and strict modes)
116
+
117
+ ### LearnMD
118
+
119
+ - Frontmatter metadata (lang, estimated_time, tags)
120
+ - Section hierarchy (## modules, ### lessons)
121
+ - Special fenced blocks: `summary`, `example`, `note`, `tip`, `warning`, `quiz`, etc.
122
+ - GFM callout detection (`> [!tip]`, `> [!warning]`, etc.)
123
+ - `!import` directives (`.learn.md` and `.quiz.md`)
124
+ - Validation (lenient and strict modes)
125
+
126
+ ### LearnSpec suite (v0.2)
127
+
128
+ Every format follows the same shape:
129
+
130
+ - YAML frontmatter with the universal fields (`lang` required, `license`, `spec_version`, `created`, `updated`)
131
+ - Cross-format directives `!import`, `!ref`, `!checkpoint` (when applicable) — see `pylearnspec.common.directives`
132
+ - `parse_<format>(content)` returns a typed dataclass with a `.to_dict()` method
133
+ - `validate_<format>(content, strict=False)` returns a list of `Diagnostic(level, message)`
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ pip install -e ".[dev]"
139
+ pytest
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,118 @@
1
+ # pylearnspec
2
+
3
+ Python parser and validator for the [LearnSpec](https://learnspec.org) suite of open standards:
4
+
5
+ | Format | API |
6
+ |---|---|
7
+ | **LearnMD** — instructional content | `parse_learn`, `validate_learn` |
8
+ | **QuizMD** — assessments | `parse_quiz`, `validate_quiz` |
9
+ | **FlashMD** — flashcards | `parse_flash`, `validate_flash` |
10
+ | **NuggetMD** — micro-learning nuggets | `parse_nugget`, `validate_nugget` |
11
+ | **TrackMD** — learning paths | `parse_track`, `validate_track` |
12
+ | **DiagramMD** — diagrams | `parse_diagram`, `validate_diagram` |
13
+ | **MediaMD** — media catalogues | `parse_media`, `validate_media` |
14
+ | **GlossaryMD** — glossaries | `parse_glossary`, `validate_glossary` |
15
+ | **BadgeMD** — micro-credentials | `parse_badge`, `validate_badge` |
16
+ | **CertMD** — macro-credentials | `parse_cert`, `validate_cert` |
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install pylearnspec
22
+ ```
23
+
24
+ ## Quick start
25
+
26
+ ### Parse a QuizMD file
27
+
28
+ ```python
29
+ from pylearnspec import parse_quiz
30
+
31
+ with open("my-quiz.quiz.md") as f:
32
+ quiz = parse_quiz(f.read())
33
+
34
+ print(quiz.title)
35
+ print(f"{len(quiz.questions)} questions")
36
+
37
+ for q in quiz.questions:
38
+ print(f" Q{q.number} ({q.q_type}): {q.title}")
39
+ ```
40
+
41
+ ### Parse a LearnMD file
42
+
43
+ ```python
44
+ from pylearnspec import parse_learn
45
+
46
+ with open("my-lesson.learn.md") as f:
47
+ lesson = parse_learn(f.read())
48
+
49
+ print(lesson.title)
50
+ for section in lesson.sections:
51
+ print(f" {'#' * section.depth} {section.title}")
52
+ ```
53
+
54
+ ### Validate
55
+
56
+ ```python
57
+ from pylearnspec import validate_quiz, validate_learn
58
+
59
+ # Lenient mode (default)
60
+ diagnostics = validate_quiz(content)
61
+
62
+ # Strict mode (for CI)
63
+ diagnostics = validate_quiz(content, strict=True)
64
+
65
+ for d in diagnostics:
66
+ print(f"[{d.level}] {d.message}")
67
+ ```
68
+
69
+ ### JSON output
70
+
71
+ Both `Quiz` and `Lesson` objects have a `.to_dict()` method for JSON serialization:
72
+
73
+ ```python
74
+ import json
75
+ from pylearnspec import parse_quiz
76
+
77
+ quiz = parse_quiz(content)
78
+ print(json.dumps(quiz.to_dict(), indent=2))
79
+ ```
80
+
81
+ ## Supported features
82
+
83
+ ### QuizMD
84
+
85
+ - Frontmatter (Level 1) and per-question `quiz` blocks (Level 2)
86
+ - Question types: MCQ, multi-select, true/false, open answer, match, order
87
+ - Per-choice and global feedback (`[!correct]`, `[!incorrect]`)
88
+ - `!import` directives
89
+ - Validation (lenient and strict modes)
90
+
91
+ ### LearnMD
92
+
93
+ - Frontmatter metadata (lang, estimated_time, tags)
94
+ - Section hierarchy (## modules, ### lessons)
95
+ - Special fenced blocks: `summary`, `example`, `note`, `tip`, `warning`, `quiz`, etc.
96
+ - GFM callout detection (`> [!tip]`, `> [!warning]`, etc.)
97
+ - `!import` directives (`.learn.md` and `.quiz.md`)
98
+ - Validation (lenient and strict modes)
99
+
100
+ ### LearnSpec suite (v0.2)
101
+
102
+ Every format follows the same shape:
103
+
104
+ - YAML frontmatter with the universal fields (`lang` required, `license`, `spec_version`, `created`, `updated`)
105
+ - Cross-format directives `!import`, `!ref`, `!checkpoint` (when applicable) — see `pylearnspec.common.directives`
106
+ - `parse_<format>(content)` returns a typed dataclass with a `.to_dict()` method
107
+ - `validate_<format>(content, strict=False)` returns a list of `Diagnostic(level, message)`
108
+
109
+ ## Development
110
+
111
+ ```bash
112
+ pip install -e ".[dev]"
113
+ pytest
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64,<77", "setuptools-scm"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pylearnspec"
7
+ version = "0.4.0"
8
+ description = "Python parser and validator for the LearnSpec suite (LearnMD, QuizMD, FlashMD, TrackMD, DiagramMD, MediaMD, GlossaryMD, BadgeMD, CertMD, NuggetMD)"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "learnspec", email = "hello@learnspec.org" }]
13
+ keywords = ["learnspec", "quizmd", "learnmd", "flashmd", "trackmd", "diagrammd", "mediamd", "glossarymd", "badgemd", "certmd", "nuggetmd", "parser", "education", "assessment"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Education",
24
+ "Topic :: Text Processing :: Markup :: Markdown",
25
+ ]
26
+
27
+ dependencies = [
28
+ "pyyaml>=6.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "pytest-cov>=5.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/learnspec/pylearnspec"
39
+ Repository = "https://github.com/learnspec/pylearnspec"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )