md-adf 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.
- md_adf-0.1.0/LICENSE +21 -0
- md_adf-0.1.0/PKG-INFO +105 -0
- md_adf-0.1.0/README.md +90 -0
- md_adf-0.1.0/pyproject.toml +38 -0
- md_adf-0.1.0/setup.cfg +4 -0
- md_adf-0.1.0/src/md_adf/__init__.py +16 -0
- md_adf-0.1.0/src/md_adf/adf/__init__.py +4 -0
- md_adf-0.1.0/src/md_adf/adf/normalize.py +1 -0
- md_adf-0.1.0/src/md_adf/adf/types.py +21 -0
- md_adf-0.1.0/src/md_adf/adf/validate.py +210 -0
- md_adf-0.1.0/src/md_adf/convert/__init__.py +4 -0
- md_adf-0.1.0/src/md_adf/convert/adf_to_markdown.py +809 -0
- md_adf-0.1.0/src/md_adf/convert/markdown_to_adf.py +450 -0
- md_adf-0.1.0/src/md_adf/diagnostics/__init__.py +3 -0
- md_adf-0.1.0/src/md_adf/diagnostics/diagnostic.py +17 -0
- md_adf-0.1.0/src/md_adf/markdown/__init__.py +1 -0
- md_adf-0.1.0/src/md_adf/markdown/escape.py +50 -0
- md_adf-0.1.0/src/md_adf/markdown/render.py +1 -0
- md_adf-0.1.0/src/md_adf/options.py +84 -0
- md_adf-0.1.0/src/md_adf/py.typed +1 -0
- md_adf-0.1.0/src/md_adf/schemas/adf-schema.json +2963 -0
- md_adf-0.1.0/src/md_adf.egg-info/PKG-INFO +105 -0
- md_adf-0.1.0/src/md_adf.egg-info/SOURCES.txt +30 -0
- md_adf-0.1.0/src/md_adf.egg-info/dependency_links.txt +1 -0
- md_adf-0.1.0/src/md_adf.egg-info/entry_points.txt +2 -0
- md_adf-0.1.0/src/md_adf.egg-info/requires.txt +2 -0
- md_adf-0.1.0/src/md_adf.egg-info/top_level.txt +2 -0
- md_adf-0.1.0/src/md_adf_cli/__main__.py +182 -0
- md_adf-0.1.0/tests/test_adf_validation.py +33 -0
- md_adf-0.1.0/tests/test_cli.py +99 -0
- md_adf-0.1.0/tests/test_conformance.py +27 -0
- md_adf-0.1.0/tests/test_options.py +66 -0
md_adf-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Markdown-ADF contributors
|
|
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.
|
md_adf-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: md-adf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Markdown-ADF converter for Python.
|
|
5
|
+
Author: Markdown-ADF contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Typing :: Typed
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: jsonschema<5.0.0,>=4.26.0
|
|
13
|
+
Requires-Dist: mistune<4.0.0,>=3.2.1
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# md-adf
|
|
17
|
+
|
|
18
|
+
Typed Python implementation of the Markdown-ADF converter.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install md-adf
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Python 3.10 or newer.
|
|
27
|
+
|
|
28
|
+
## API
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from md_adf import (
|
|
32
|
+
adf_to_markdown,
|
|
33
|
+
markdown_to_adf,
|
|
34
|
+
parse_adf,
|
|
35
|
+
validate_adf,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
markdown_result = adf_to_markdown(adf_document)
|
|
39
|
+
print(markdown_result.value)
|
|
40
|
+
print(markdown_result.diagnostics)
|
|
41
|
+
|
|
42
|
+
adf_result = markdown_to_adf("## Hello\n\nThis is **ADF**.")
|
|
43
|
+
print(adf_result.value)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Results have this shape:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ConversionResult(Generic[T]):
|
|
51
|
+
value: T
|
|
52
|
+
diagnostics: list[Diagnostic]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Current `ConversionOptions` support:
|
|
56
|
+
|
|
57
|
+
- `markdown_dialect`: only `gfm` is supported; other values are rejected. Dict options may also use `markdownDialect`.
|
|
58
|
+
- `profile`: `jira`, `confluence`, and `portableMarkdown` are accepted for API and CLI parity, but do not change Phase 1 conversion behavior yet.
|
|
59
|
+
- `validate_adf`: defaults to `True`; set to `False` to skip pinned schema validation for ADF-to-Markdown input. Dict options may also use `validateAdf`.
|
|
60
|
+
- `normalize_adf`: accepted for future normalization controls. It is currently a no-op because Phase 1 Markdown-to-ADF output is already normalized where supported. Dict options may also use `normalizeAdf`.
|
|
61
|
+
|
|
62
|
+
ADF-to-Markdown returns a string with trailing whitespace trimmed and no final newline. The CLI writes that string exactly.
|
|
63
|
+
|
|
64
|
+
## CLI
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
md-adf to-md input.adf.json --output output.md
|
|
68
|
+
md-adf to-adf input.md --output output.adf.json
|
|
69
|
+
md-adf validate-adf input.adf.json
|
|
70
|
+
cat input.md | md-adf to-adf --profile jira
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Supported profiles are `jira`, `confluence`, and `portableMarkdown`. Diagnostics are emitted as JSON lines to stderr.
|
|
74
|
+
|
|
75
|
+
## Current Support
|
|
76
|
+
|
|
77
|
+
- ADF validation/parsing against a pinned ADF schema.
|
|
78
|
+
- ADF to Markdown for `doc`, `paragraph`, `heading`, `blockquote`, `bulletList`, `orderedList`, `listItem`, `codeBlock`, `rule`, simple GFM tables, task lists, media link/text fallback, rich inline text fallback for `mention`, `emoji`, `date`, and `status`, `text`, and `hardBreak`.
|
|
79
|
+
- Markdown to ADF for paragraphs, headings, block quotes, lists, GFM task lists, code blocks, thematic breaks, GFM tables, text, hard breaks, soft breaks, links, images as link text fallback, and raw HTML as text fallback.
|
|
80
|
+
- Marks for `strong`, `em`, `strike`, `code`, and `link`.
|
|
81
|
+
- Structured diagnostics for invalid ADF roots, unsupported ADF nodes/marks, invalid containers, invalid link marks, complex table omission, task-list fallback, image/media fallback, rich inline fallback, and dropped rich inline attributes.
|
|
82
|
+
|
|
83
|
+
## Known Limitations
|
|
84
|
+
|
|
85
|
+
- ADF to Markdown only renders simple rectangular tables as GFM pipe tables; complex tables are omitted with diagnostics.
|
|
86
|
+
- Media is represented as Markdown link/text fallback; no media URL resolver or image emission exists yet.
|
|
87
|
+
- Markdown to ADF maps images to linked text fallback instead of ADF media nodes.
|
|
88
|
+
- Mixed or complex GFM task lists may fall back to ordinary list items with diagnostics.
|
|
89
|
+
- ADF to Markdown renders mentions, emoji, dates, and statuses as text fallbacks; Markdown to ADF keeps that text as normal text and does not recreate rich inline node IDs.
|
|
90
|
+
- ADF to Markdown does not yet render panels, expands, cards, layout nodes, extensions, or color/underline/subscript/superscript marks.
|
|
91
|
+
- Markdown raw HTML is preserved as text fallback; it is not interpreted into rich ADF.
|
|
92
|
+
- Unsupported ADF nodes are omitted with diagnostics.
|
|
93
|
+
- Markdown parser AST access is not exposed as public API yet; conversion uses real Markdown parsers internally.
|
|
94
|
+
|
|
95
|
+
## Development
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
poetry -C packages/python run python ../../tools/conformance/run-python.py
|
|
99
|
+
poetry -C packages/python run pytest
|
|
100
|
+
poetry -C packages/python run ruff check --config ../../pyproject.toml src tests ../../tools/conformance/run-python.py
|
|
101
|
+
poetry -C packages/python run mypy --config-file ../../pyproject.toml src tests ../../tools/conformance/run-python.py
|
|
102
|
+
npm run test:packages
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Shared conformance fixtures live in `../../fixtures` and are run by the root `just test-python` command.
|
md_adf-0.1.0/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# md-adf
|
|
2
|
+
|
|
3
|
+
Typed Python implementation of the Markdown-ADF converter.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install md-adf
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Python 3.10 or newer.
|
|
12
|
+
|
|
13
|
+
## API
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from md_adf import (
|
|
17
|
+
adf_to_markdown,
|
|
18
|
+
markdown_to_adf,
|
|
19
|
+
parse_adf,
|
|
20
|
+
validate_adf,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
markdown_result = adf_to_markdown(adf_document)
|
|
24
|
+
print(markdown_result.value)
|
|
25
|
+
print(markdown_result.diagnostics)
|
|
26
|
+
|
|
27
|
+
adf_result = markdown_to_adf("## Hello\n\nThis is **ADF**.")
|
|
28
|
+
print(adf_result.value)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Results have this shape:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class ConversionResult(Generic[T]):
|
|
36
|
+
value: T
|
|
37
|
+
diagnostics: list[Diagnostic]
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Current `ConversionOptions` support:
|
|
41
|
+
|
|
42
|
+
- `markdown_dialect`: only `gfm` is supported; other values are rejected. Dict options may also use `markdownDialect`.
|
|
43
|
+
- `profile`: `jira`, `confluence`, and `portableMarkdown` are accepted for API and CLI parity, but do not change Phase 1 conversion behavior yet.
|
|
44
|
+
- `validate_adf`: defaults to `True`; set to `False` to skip pinned schema validation for ADF-to-Markdown input. Dict options may also use `validateAdf`.
|
|
45
|
+
- `normalize_adf`: accepted for future normalization controls. It is currently a no-op because Phase 1 Markdown-to-ADF output is already normalized where supported. Dict options may also use `normalizeAdf`.
|
|
46
|
+
|
|
47
|
+
ADF-to-Markdown returns a string with trailing whitespace trimmed and no final newline. The CLI writes that string exactly.
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
md-adf to-md input.adf.json --output output.md
|
|
53
|
+
md-adf to-adf input.md --output output.adf.json
|
|
54
|
+
md-adf validate-adf input.adf.json
|
|
55
|
+
cat input.md | md-adf to-adf --profile jira
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Supported profiles are `jira`, `confluence`, and `portableMarkdown`. Diagnostics are emitted as JSON lines to stderr.
|
|
59
|
+
|
|
60
|
+
## Current Support
|
|
61
|
+
|
|
62
|
+
- ADF validation/parsing against a pinned ADF schema.
|
|
63
|
+
- ADF to Markdown for `doc`, `paragraph`, `heading`, `blockquote`, `bulletList`, `orderedList`, `listItem`, `codeBlock`, `rule`, simple GFM tables, task lists, media link/text fallback, rich inline text fallback for `mention`, `emoji`, `date`, and `status`, `text`, and `hardBreak`.
|
|
64
|
+
- Markdown to ADF for paragraphs, headings, block quotes, lists, GFM task lists, code blocks, thematic breaks, GFM tables, text, hard breaks, soft breaks, links, images as link text fallback, and raw HTML as text fallback.
|
|
65
|
+
- Marks for `strong`, `em`, `strike`, `code`, and `link`.
|
|
66
|
+
- Structured diagnostics for invalid ADF roots, unsupported ADF nodes/marks, invalid containers, invalid link marks, complex table omission, task-list fallback, image/media fallback, rich inline fallback, and dropped rich inline attributes.
|
|
67
|
+
|
|
68
|
+
## Known Limitations
|
|
69
|
+
|
|
70
|
+
- ADF to Markdown only renders simple rectangular tables as GFM pipe tables; complex tables are omitted with diagnostics.
|
|
71
|
+
- Media is represented as Markdown link/text fallback; no media URL resolver or image emission exists yet.
|
|
72
|
+
- Markdown to ADF maps images to linked text fallback instead of ADF media nodes.
|
|
73
|
+
- Mixed or complex GFM task lists may fall back to ordinary list items with diagnostics.
|
|
74
|
+
- ADF to Markdown renders mentions, emoji, dates, and statuses as text fallbacks; Markdown to ADF keeps that text as normal text and does not recreate rich inline node IDs.
|
|
75
|
+
- ADF to Markdown does not yet render panels, expands, cards, layout nodes, extensions, or color/underline/subscript/superscript marks.
|
|
76
|
+
- Markdown raw HTML is preserved as text fallback; it is not interpreted into rich ADF.
|
|
77
|
+
- Unsupported ADF nodes are omitted with diagnostics.
|
|
78
|
+
- Markdown parser AST access is not exposed as public API yet; conversion uses real Markdown parsers internally.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
poetry -C packages/python run python ../../tools/conformance/run-python.py
|
|
84
|
+
poetry -C packages/python run pytest
|
|
85
|
+
poetry -C packages/python run ruff check --config ../../pyproject.toml src tests ../../tools/conformance/run-python.py
|
|
86
|
+
poetry -C packages/python run mypy --config-file ../../pyproject.toml src tests ../../tools/conformance/run-python.py
|
|
87
|
+
npm run test:packages
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Shared conformance fixtures live in `../../fixtures` and are run by the root `just test-python` command.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "md-adf"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Markdown-ADF converter for Python."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Markdown-ADF contributors" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Typing :: Typed"
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"jsonschema>=4.26.0,<5.0.0",
|
|
21
|
+
"mistune>=3.2.1,<4.0.0"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
md-adf = "md_adf_cli.__main__:main"
|
|
26
|
+
|
|
27
|
+
[tool.poetry.group.dev.dependencies]
|
|
28
|
+
mypy = ">=1.0"
|
|
29
|
+
pytest = ">=8.0"
|
|
30
|
+
ruff = ">=0.4"
|
|
31
|
+
types-jsonschema = "^4.26.0.20260408"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
include = ["md_adf*", "md_adf_cli*"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
md_adf = ["py.typed", "schemas/*.json"]
|
md_adf-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Markdown-ADF converter package."""
|
|
2
|
+
|
|
3
|
+
from .adf.validate import parse_adf, validate_adf
|
|
4
|
+
from .convert.adf_to_markdown import adf_to_markdown
|
|
5
|
+
from .convert.markdown_to_adf import markdown_to_adf
|
|
6
|
+
from .options import ConversionOptions, ConversionOptionsInput, ConversionResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ConversionResult",
|
|
10
|
+
"ConversionOptions",
|
|
11
|
+
"ConversionOptionsInput",
|
|
12
|
+
"adf_to_markdown",
|
|
13
|
+
"markdown_to_adf",
|
|
14
|
+
"parse_adf",
|
|
15
|
+
"validate_adf",
|
|
16
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""ADF normalization placeholder."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AdfNode(TypedDict, total=False):
|
|
7
|
+
"""Minimal structural representation for ADF nodes used by the converters."""
|
|
8
|
+
|
|
9
|
+
type: str
|
|
10
|
+
attrs: dict[str, Any]
|
|
11
|
+
marks: list[dict[str, Any]]
|
|
12
|
+
content: list["AdfNode"]
|
|
13
|
+
text: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdfDocument(TypedDict):
|
|
17
|
+
"""Root ADF document shape accepted and emitted by this package."""
|
|
18
|
+
|
|
19
|
+
version: int
|
|
20
|
+
type: str
|
|
21
|
+
content: list[AdfNode]
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from jsonschema import Draft4Validator
|
|
9
|
+
from jsonschema.exceptions import ValidationError
|
|
10
|
+
|
|
11
|
+
from .types import AdfDocument
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ParseAdfOptions:
|
|
16
|
+
"""Options controlling how unknown values are parsed as ADF."""
|
|
17
|
+
|
|
18
|
+
validate_adf: bool = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ValidationResult:
|
|
23
|
+
"""Structured success/error result for ADF validation."""
|
|
24
|
+
|
|
25
|
+
valid: bool
|
|
26
|
+
errors: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_adf(value: Any, options: Any = None) -> AdfDocument:
|
|
30
|
+
"""Parse an unknown value as an ADF document, optionally validating schema."""
|
|
31
|
+
|
|
32
|
+
if (
|
|
33
|
+
isinstance(value, dict)
|
|
34
|
+
and value.get("version") == 1
|
|
35
|
+
and value.get("type") == "doc"
|
|
36
|
+
and isinstance(value.get("content"), list)
|
|
37
|
+
):
|
|
38
|
+
if _validate_enabled(options):
|
|
39
|
+
errors = list(_VALIDATOR.iter_errors(value))
|
|
40
|
+
if errors:
|
|
41
|
+
raise ValueError(_format_schema_error(_most_specific_error(errors, value)))
|
|
42
|
+
return cast(AdfDocument, value)
|
|
43
|
+
|
|
44
|
+
raise ValueError("Invalid ADF root: expected doc version 1.")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_adf(value: Any, options: Any = None) -> ValidationResult:
|
|
48
|
+
"""Validate an unknown value as ADF without raising validation errors."""
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
parse_adf(value, options)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
return ValidationResult(valid=False, errors=[str(exc)])
|
|
54
|
+
|
|
55
|
+
return ValidationResult(valid=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _schema_path() -> Path:
|
|
59
|
+
"""Find the pinned ADF JSON Schema in source or packaged layouts."""
|
|
60
|
+
|
|
61
|
+
here = Path(__file__).resolve()
|
|
62
|
+
candidates = [
|
|
63
|
+
here.parents[1] / "schemas" / "adf-schema.json",
|
|
64
|
+
here.parents[5] / "schemas" / "adf-schema.json",
|
|
65
|
+
]
|
|
66
|
+
for candidate in candidates:
|
|
67
|
+
if candidate.exists():
|
|
68
|
+
return candidate
|
|
69
|
+
|
|
70
|
+
raise RuntimeError("Pinned ADF JSON Schema not found.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_SCHEMA = json.loads(_schema_path().read_text(encoding="utf-8"))
|
|
74
|
+
_VALIDATOR = Draft4Validator(_SCHEMA)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _validate_enabled(options: Any) -> bool:
|
|
78
|
+
"""Read the validate_adf option from dataclass or mapping-style options."""
|
|
79
|
+
|
|
80
|
+
if options is None:
|
|
81
|
+
return True
|
|
82
|
+
if isinstance(options, ParseAdfOptions):
|
|
83
|
+
return options.validate_adf
|
|
84
|
+
if hasattr(options, "validate_adf"):
|
|
85
|
+
return bool(options.validate_adf)
|
|
86
|
+
value = options.get("validate_adf", options.get("validateAdf", True))
|
|
87
|
+
return bool(value)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _format_schema_error(error: ValidationError) -> str:
|
|
91
|
+
"""Convert a jsonschema validation error into a concise user message."""
|
|
92
|
+
|
|
93
|
+
path = _json_pointer(error.absolute_path)
|
|
94
|
+
location = f" at {path}" if path else ""
|
|
95
|
+
|
|
96
|
+
if error.validator == "required":
|
|
97
|
+
missing = _required_property(error.message)
|
|
98
|
+
return f"Invalid ADF{location}: missing required property '{missing}'."
|
|
99
|
+
if error.validator == "additionalProperties":
|
|
100
|
+
property_name = _additional_property(error.message)
|
|
101
|
+
return f"Invalid ADF{location}: unexpected property '{property_name}'."
|
|
102
|
+
if error.validator == "enum":
|
|
103
|
+
return f"Invalid ADF{location}: value is not allowed."
|
|
104
|
+
if error.validator == "type":
|
|
105
|
+
return f"Invalid ADF{location}: expected {error.validator_value}."
|
|
106
|
+
if error.validator == "minLength":
|
|
107
|
+
return f"Invalid ADF{location}: expected a non-empty string."
|
|
108
|
+
if error.validator == "minItems":
|
|
109
|
+
return f"Invalid ADF{location}: expected at least one item."
|
|
110
|
+
return f"Invalid ADF{location}: failed schema constraint '{error.validator}'."
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _most_specific_error(errors: list[ValidationError], value: Any) -> ValidationError:
|
|
114
|
+
"""Choose the schema error that best identifies the invalid ADF location."""
|
|
115
|
+
|
|
116
|
+
candidates = _leaf_errors(errors)
|
|
117
|
+
relevant = [error for error in candidates if error.validator not in {"anyOf", "oneOf"}]
|
|
118
|
+
matching_type_error = next(
|
|
119
|
+
(error for error in relevant if _error_matches_actual_type(error, value)),
|
|
120
|
+
None,
|
|
121
|
+
)
|
|
122
|
+
if matching_type_error is not None:
|
|
123
|
+
return matching_type_error
|
|
124
|
+
return max(relevant or candidates, key=_error_specificity)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _leaf_errors(errors: list[ValidationError]) -> list[ValidationError]:
|
|
128
|
+
"""Flatten nested anyOf/oneOf validation contexts to leaf errors."""
|
|
129
|
+
|
|
130
|
+
leaves: list[ValidationError] = []
|
|
131
|
+
for error in errors:
|
|
132
|
+
if error.context:
|
|
133
|
+
leaves.extend(_leaf_errors(list(error.context)))
|
|
134
|
+
else:
|
|
135
|
+
leaves.append(error)
|
|
136
|
+
return leaves
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _json_pointer(path: Any) -> str:
|
|
140
|
+
"""Render a jsonschema path as an escaped JSON Pointer."""
|
|
141
|
+
|
|
142
|
+
parts = [str(part).replace("~", "~0").replace("/", "~1") for part in path]
|
|
143
|
+
return "/" + "/".join(parts) if parts else ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _error_specificity(error: ValidationError) -> int:
|
|
147
|
+
"""Score schema errors so deeper paths are considered more specific."""
|
|
148
|
+
|
|
149
|
+
base = len(error.absolute_path)
|
|
150
|
+
if error.validator == "required" and "/attrs" in _json_pointer(error.absolute_path):
|
|
151
|
+
return base + 1
|
|
152
|
+
return base
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _additional_property(message: str) -> str:
|
|
156
|
+
"""Extract an additional-property name from a jsonschema error message."""
|
|
157
|
+
|
|
158
|
+
marker = "'"
|
|
159
|
+
start = message.find(marker)
|
|
160
|
+
end = message.find(marker, start + 1)
|
|
161
|
+
return message[start + 1 : end] if start >= 0 and end > start else "unknown"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _error_matches_actual_type(error: ValidationError, value: Any) -> bool:
|
|
165
|
+
"""Check whether a required-property error matches the node's actual type."""
|
|
166
|
+
|
|
167
|
+
if error.validator != "required":
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
parent_path = list(error.absolute_path)
|
|
171
|
+
if parent_path[-1:] == ["attrs"]:
|
|
172
|
+
parent_path = parent_path[:-1]
|
|
173
|
+
parent = _value_at_path(value, parent_path)
|
|
174
|
+
if (
|
|
175
|
+
isinstance(parent, dict)
|
|
176
|
+
and parent.get("type") == "link"
|
|
177
|
+
and _required_property(error.message) == "href"
|
|
178
|
+
):
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
schema_path = list(error.schema_path)
|
|
182
|
+
if len(schema_path) < 2 or schema_path[0] != "definitions":
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
definition = str(schema_path[1])
|
|
186
|
+
expected_type = definition.removesuffix("_mark").removesuffix("_node")
|
|
187
|
+
return isinstance(parent, dict) and parent.get("type") == expected_type
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _value_at_path(value: Any, path: list[Any]) -> Any:
|
|
191
|
+
"""Resolve a jsonschema path against a nested Python value."""
|
|
192
|
+
|
|
193
|
+
current = value
|
|
194
|
+
for part in path:
|
|
195
|
+
if isinstance(current, list) and isinstance(part, int):
|
|
196
|
+
current = current[part]
|
|
197
|
+
elif isinstance(current, dict):
|
|
198
|
+
current = current.get(part)
|
|
199
|
+
else:
|
|
200
|
+
return None
|
|
201
|
+
return current
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _required_property(message: str) -> str:
|
|
205
|
+
"""Extract a required-property name from a jsonschema error message."""
|
|
206
|
+
|
|
207
|
+
marker = "'"
|
|
208
|
+
start = message.find(marker)
|
|
209
|
+
end = message.find(marker, start + 1)
|
|
210
|
+
return message[start + 1 : end] if start >= 0 and end > start else "unknown"
|