jmesway 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.
- jmesway-0.1.0/LICENSE +21 -0
- jmesway-0.1.0/PKG-INFO +36 -0
- jmesway-0.1.0/README_PYPI.md +10 -0
- jmesway-0.1.0/pyproject.toml +46 -0
- jmesway-0.1.0/pyproject.toml.orig +48 -0
- jmesway-0.1.0/src/jmesway/__init__.py +18 -0
- jmesway-0.1.0/src/jmesway/cli.py +240 -0
- jmesway-0.1.0/src/jmesway/jmespath_transform.py +281 -0
- jmesway-0.1.0/src/jmesway/roadmap.py +299 -0
- jmesway-0.1.0/src/jmesway/transpiler.py +58 -0
jmesway-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alpine
|
|
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.
|
jmesway-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jmesway
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: JMESPath workflow management tool
|
|
5
|
+
Keywords:
|
|
6
|
+
Author: alpine
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Text Processing
|
|
19
|
+
Requires-Dist: jmespath>=1.1.0
|
|
20
|
+
Requires-Dist: json5>=0.15.0 ; extra == 'cli'
|
|
21
|
+
Requires-Dist: rich>=15.0.0 ; extra == 'cli'
|
|
22
|
+
Requires-Dist: watchfiles>=1.2.0 ; extra == 'cli'
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Provides-Extra: cli
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Jmesway
|
|
28
|
+
|
|
29
|
+
[](https://www.python.org/downloads/)
|
|
30
|
+
|
|
31
|
+
> ⚠️ This is an early-stage personal project. The API and file format may still change.
|
|
32
|
+
|
|
33
|
+
Write JSON5 input/output examples and JMESPath expressions in a single Markdown
|
|
34
|
+
file (`.roadmap.md`), and get a spec, a test case, and a JSON → JSON
|
|
35
|
+
transformation, all in one place.
|
|
36
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Jmesway
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/downloads/)
|
|
4
|
+
|
|
5
|
+
> ⚠️ This is an early-stage personal project. The API and file format may still change.
|
|
6
|
+
|
|
7
|
+
Write JSON5 input/output examples and JMESPath expressions in a single Markdown
|
|
8
|
+
file (`.roadmap.md`), and get a spec, a test case, and a JSON → JSON
|
|
9
|
+
transformation, all in one place.
|
|
10
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jmesway"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "JMESPath workflow management tool"
|
|
5
|
+
readme = "README_PYPI.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
keywords = []
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
"Topic :: Text Processing",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.10"
|
|
22
|
+
dependencies = ["jmespath>=1.1.0"]
|
|
23
|
+
|
|
24
|
+
[[project.authors]]
|
|
25
|
+
name = "alpine"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
cli = [
|
|
29
|
+
"json5>=0.15.0",
|
|
30
|
+
"rich>=15.0.0",
|
|
31
|
+
"watchfiles>=1.2.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
jmesway = "jmesway:main"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"jmesway[cli]",
|
|
44
|
+
"pytest>=9.1.1",
|
|
45
|
+
"ruff>=0.16.7",
|
|
46
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jmesway"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "JMESPath workflow management tool"
|
|
5
|
+
readme = "README_PYPI.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "alpine" },
|
|
10
|
+
]
|
|
11
|
+
keywords = []
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Topic :: Text Processing",
|
|
23
|
+
]
|
|
24
|
+
requires-python = ">=3.10"
|
|
25
|
+
dependencies = [
|
|
26
|
+
"jmespath>=1.1.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
cli = [
|
|
31
|
+
"json5>=0.15.0",
|
|
32
|
+
"rich>=15.0.0",
|
|
33
|
+
"watchfiles>=1.2.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
jmesway = "jmesway:main"
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.12.5,<0.13.0"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"jmesway[cli]",
|
|
46
|
+
"pytest>=9.1.1",
|
|
47
|
+
"ruff>=0.16.7",
|
|
48
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""``main`` は CLI 用の遅延インポート。ランタイム利用(例: ``jmesway.jmespath_transform``)は
|
|
2
|
+
`jmespath` のみで完結し、`json5`/`rich`/`watchfiles`(``cli`` extra)を要求しない。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
__all__ = ["main"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str) -> Any:
|
|
13
|
+
if name == "main":
|
|
14
|
+
from .cli import main
|
|
15
|
+
|
|
16
|
+
return main
|
|
17
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
18
|
+
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""jmesway CLI: `.roadmap.md` の自己検証・単発実行・Python コード生成。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.syntax import Syntax
|
|
15
|
+
from watchfiles import watch
|
|
16
|
+
|
|
17
|
+
from .roadmap import CaseVerificationError, RoadmapDocument
|
|
18
|
+
from .transpiler import generate_source, output_path_for
|
|
19
|
+
|
|
20
|
+
_out = Console()
|
|
21
|
+
_err = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_json_input(source: str) -> Any:
|
|
25
|
+
"""``-`` なら標準入力、それ以外はファイルパスとして JSON を読み込む。"""
|
|
26
|
+
if source == "-":
|
|
27
|
+
return json.loads(sys.stdin.read())
|
|
28
|
+
return json.loads(Path(source).read_text(encoding="utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _json_panel(title: str, value: Any, border_style: str) -> Panel:
|
|
32
|
+
body = json.dumps(value, ensure_ascii=False, indent=2)
|
|
33
|
+
return Panel(
|
|
34
|
+
Syntax(body, "json", background_color="default"),
|
|
35
|
+
title=title,
|
|
36
|
+
title_align="left",
|
|
37
|
+
border_style=border_style,
|
|
38
|
+
expand=False,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _print_verification_error(path: Path, error: CaseVerificationError) -> None:
|
|
43
|
+
"""どの CASE / JMESPath 式が失敗したかを、コード部分をパネルとして分離して表示する。"""
|
|
44
|
+
case = error.case
|
|
45
|
+
_err.print(f"[bold red]NG[/bold red] {path}")
|
|
46
|
+
_err.print(f" [bold]CASE:[/bold] {case.title} (line {case.line})")
|
|
47
|
+
_err.print(
|
|
48
|
+
Panel(
|
|
49
|
+
Syntax(error.expression, "text", background_color="default", word_wrap=True),
|
|
50
|
+
title=f"JMESPATH (step {error.step_index})",
|
|
51
|
+
title_align="left",
|
|
52
|
+
border_style="cyan",
|
|
53
|
+
expand=False,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
_err.print(_json_panel("expected", error.expected, "green"))
|
|
57
|
+
_err.print(_json_panel("actual", error.actual, "red"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _error_to_dict(error: Exception) -> dict[str, Any]:
|
|
61
|
+
"""エラーを AI / ツール向けの JSON として機械判読可能な形に分解する。"""
|
|
62
|
+
if isinstance(error, CaseVerificationError):
|
|
63
|
+
case = error.case
|
|
64
|
+
return {
|
|
65
|
+
"error_type": "case_verification",
|
|
66
|
+
"case": {"title": case.title, "line": case.line},
|
|
67
|
+
"step_index": error.step_index,
|
|
68
|
+
"expression": error.expression,
|
|
69
|
+
"expected": error.expected,
|
|
70
|
+
"actual": error.actual,
|
|
71
|
+
}
|
|
72
|
+
return {"error_type": "parse", "message": str(error)}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _print_json(result: dict[str, Any]) -> None:
|
|
76
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _check_once(files: Sequence[str], *, as_json: bool = False) -> int:
|
|
80
|
+
exit_code = 0
|
|
81
|
+
for file in files:
|
|
82
|
+
path = Path(file)
|
|
83
|
+
try:
|
|
84
|
+
doc = RoadmapDocument.load(path)
|
|
85
|
+
doc.verify_all()
|
|
86
|
+
except (ValueError, AssertionError) as e:
|
|
87
|
+
exit_code = 1
|
|
88
|
+
if as_json:
|
|
89
|
+
_print_json({"path": str(path), "status": "ng", **_error_to_dict(e)})
|
|
90
|
+
elif isinstance(e, CaseVerificationError):
|
|
91
|
+
_print_verification_error(path, e)
|
|
92
|
+
else:
|
|
93
|
+
_err.print(f"[bold red]NG[/bold red] {path}: {e}")
|
|
94
|
+
continue
|
|
95
|
+
if as_json:
|
|
96
|
+
_print_json({"path": str(path), "status": "ok", "cases": len(doc.cases)})
|
|
97
|
+
else:
|
|
98
|
+
_out.print(f"[bold green]OK[/bold green] {path}: {len(doc.cases)} case(s) verified")
|
|
99
|
+
return exit_code
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _check_live(files: Sequence[str], *, as_json: bool = False) -> int:
|
|
103
|
+
"""指定ファイルの変更を検知するたびに再検証する(Ctrl+C で終了)。"""
|
|
104
|
+
paths = [Path(file) for file in files]
|
|
105
|
+
_check_once(files, as_json=as_json)
|
|
106
|
+
if not as_json:
|
|
107
|
+
_out.print("[dim]--live: watching for changes (Ctrl+C to stop)[/dim]")
|
|
108
|
+
try:
|
|
109
|
+
for _ in watch(*paths):
|
|
110
|
+
_check_once(files, as_json=as_json)
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
pass
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
117
|
+
if args.live:
|
|
118
|
+
return _check_live(args.files, as_json=args.json)
|
|
119
|
+
return _check_once(args.files, as_json=args.json)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
123
|
+
doc = RoadmapDocument.load(args.file)
|
|
124
|
+
try:
|
|
125
|
+
pipeline = doc.pipeline(args.pipeline)
|
|
126
|
+
except KeyError as e:
|
|
127
|
+
print(f"error: {e}", file=sys.stderr)
|
|
128
|
+
return 1
|
|
129
|
+
data = _load_json_input(args.input)
|
|
130
|
+
result = pipeline.transform(data)
|
|
131
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_build(args: argparse.Namespace) -> int:
|
|
136
|
+
if args.live:
|
|
137
|
+
return _build_live(args.files, check=args.check, as_json=args.json)
|
|
138
|
+
return _build_once(args.files, check=args.check, as_json=args.json)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_once(files: Sequence[str], *, check: bool, as_json: bool = False) -> int:
|
|
142
|
+
exit_code = 0
|
|
143
|
+
for file in files:
|
|
144
|
+
source_path = Path(file)
|
|
145
|
+
doc = RoadmapDocument.load(source_path)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
doc.verify_all()
|
|
149
|
+
except AssertionError as e:
|
|
150
|
+
exit_code = 1
|
|
151
|
+
if as_json:
|
|
152
|
+
_print_json({"path": str(source_path), "status": "ng", **_error_to_dict(e)})
|
|
153
|
+
elif isinstance(e, CaseVerificationError):
|
|
154
|
+
_print_verification_error(source_path, e)
|
|
155
|
+
else:
|
|
156
|
+
_err.print(f"[bold red]NG[/bold red] {source_path}: {e}")
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
out_path = output_path_for(source_path)
|
|
160
|
+
generated = generate_source(doc, source_path)
|
|
161
|
+
|
|
162
|
+
if check:
|
|
163
|
+
existing = out_path.read_text(encoding="utf-8") if out_path.exists() else None
|
|
164
|
+
if existing != generated:
|
|
165
|
+
exit_code = 1
|
|
166
|
+
if as_json:
|
|
167
|
+
_print_json(
|
|
168
|
+
{"path": str(out_path), "status": "ng", "error_type": "drift", "message": "needs regeneration"}
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
_err.print(f"[bold red]NG[/bold red] {out_path}: needs regeneration (run 'jmesway build')")
|
|
172
|
+
elif as_json:
|
|
173
|
+
_print_json({"path": str(out_path), "status": "ok", "action": "up_to_date"})
|
|
174
|
+
else:
|
|
175
|
+
_out.print(f"[bold green]OK[/bold green] {out_path}: up to date")
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
out_path.write_text(generated, encoding="utf-8")
|
|
179
|
+
if as_json:
|
|
180
|
+
_print_json({"path": str(out_path), "status": "ok", "action": "generated"})
|
|
181
|
+
else:
|
|
182
|
+
_out.print(f"[bold green]generated[/bold green] {out_path}")
|
|
183
|
+
return exit_code
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _build_live(files: Sequence[str], *, check: bool, as_json: bool = False) -> int:
|
|
187
|
+
"""指定ファイルの変更を検知するたびに再 build する(Ctrl+C で終了)。
|
|
188
|
+
|
|
189
|
+
build 忘れ(roadmap.md を編集したのに .py を再生成していない)を防ぐのが狙い。
|
|
190
|
+
"""
|
|
191
|
+
paths = [Path(file) for file in files]
|
|
192
|
+
_build_once(files, check=check, as_json=as_json)
|
|
193
|
+
if not as_json:
|
|
194
|
+
_out.print("[dim]--live: watching for changes (Ctrl+C to stop)[/dim]")
|
|
195
|
+
try:
|
|
196
|
+
for _ in watch(*paths):
|
|
197
|
+
_build_once(files, check=check, as_json=as_json)
|
|
198
|
+
except KeyboardInterrupt:
|
|
199
|
+
pass
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
204
|
+
parser = argparse.ArgumentParser(prog="jmesway", description="jmesway: JSON to JSON データ変換ユーティリティ")
|
|
205
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
206
|
+
|
|
207
|
+
check_parser = subparsers.add_parser("check", help="roadmap.md の IN/OUT を自己検証する")
|
|
208
|
+
check_parser.add_argument("files", nargs="+", help=".roadmap.md ファイル")
|
|
209
|
+
check_parser.add_argument(
|
|
210
|
+
"--live", action="store_true", help="ファイル変更を検知するたびに再検証し続ける(Ctrl+C で終了)"
|
|
211
|
+
)
|
|
212
|
+
check_parser.add_argument(
|
|
213
|
+
"--json", action="store_true", help="人間向けの装飾表示の代わりに、1行1JSONの機械可読な結果を出力する"
|
|
214
|
+
)
|
|
215
|
+
check_parser.set_defaults(func=_cmd_check)
|
|
216
|
+
|
|
217
|
+
run_parser = subparsers.add_parser("run", help="指定したパイプラインを単発実行する")
|
|
218
|
+
run_parser.add_argument("file", help=".roadmap.md ファイル")
|
|
219
|
+
run_parser.add_argument("--pipeline", required=True, help="実行するパイプライン名")
|
|
220
|
+
run_parser.add_argument("--in", dest="input", required=True, help="入力 JSON ファイル('-' で標準入力)")
|
|
221
|
+
run_parser.set_defaults(func=_cmd_run)
|
|
222
|
+
|
|
223
|
+
build_parser = subparsers.add_parser("build", help="roadmap.md から .py を事前生成する")
|
|
224
|
+
build_parser.add_argument("files", nargs="+", help=".roadmap.md ファイル")
|
|
225
|
+
build_parser.add_argument("--check", action="store_true", help="生成物との不整合を検知するのみ(書き込まない)")
|
|
226
|
+
build_parser.add_argument(
|
|
227
|
+
"--live", action="store_true", help="ファイル変更を検知するたびに再 build し続ける(Ctrl+C で終了)"
|
|
228
|
+
)
|
|
229
|
+
build_parser.add_argument(
|
|
230
|
+
"--json", action="store_true", help="人間向けの装飾表示の代わりに、1行1JSONの機械可読な結果を出力する"
|
|
231
|
+
)
|
|
232
|
+
build_parser.set_defaults(func=_cmd_build)
|
|
233
|
+
|
|
234
|
+
return parser
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
238
|
+
parser = _build_parser()
|
|
239
|
+
args = parser.parse_args(argv)
|
|
240
|
+
sys.exit(args.func(args))
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""汎用 JMESPath 変換モジュール。
|
|
2
|
+
|
|
3
|
+
特徴:
|
|
4
|
+
- 任意のオブジェクトを自動的に dict 風にラップ(呼び出し側での明示的ラップ不要)
|
|
5
|
+
- カスタム関数は型ヒント付き関数として引数で渡す形式(クラス定義不要)
|
|
6
|
+
- 式はコンパイルキャッシュによる再パース抑止
|
|
7
|
+
- アドオン関数ごとにキャッシュして再利用
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import types as _builtins
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from datetime import date, datetime
|
|
17
|
+
from decimal import Decimal
|
|
18
|
+
from functools import lru_cache
|
|
19
|
+
from logging import getLogger
|
|
20
|
+
from typing import Any, Union, get_args, get_origin, get_type_hints
|
|
21
|
+
|
|
22
|
+
import jmespath
|
|
23
|
+
from jmespath import functions
|
|
24
|
+
|
|
25
|
+
logger = getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _AsDict:
|
|
29
|
+
"""非 dict オブジェクトを jmespath で属性アクセスできるよう dict 風に包む(内部用)。
|
|
30
|
+
|
|
31
|
+
jmespath は ``value[key]`` (``__getitem__``) でフィールドアクセスするため、
|
|
32
|
+
``__getitem__`` / ``get`` を実装することで ``obj.attr`` のような dot 記法が使える。
|
|
33
|
+
返り値は ``_wrap()`` を通して再帰的に処理されるため、ネストしたオブジェクトも透過的に扱える。
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, obj: Any) -> None:
|
|
37
|
+
self._obj = obj
|
|
38
|
+
|
|
39
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
40
|
+
return _wrap(getattr(self._obj, key, default))
|
|
41
|
+
|
|
42
|
+
def __getitem__(self, key: str) -> Any:
|
|
43
|
+
return _wrap(getattr(self._obj, key))
|
|
44
|
+
|
|
45
|
+
def __contains__(self, key: str) -> bool:
|
|
46
|
+
return hasattr(self._obj, key)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _wrap(data: Any) -> Any:
|
|
50
|
+
"""任意のデータを JMESPath 評価用にラップする。
|
|
51
|
+
|
|
52
|
+
- ``None`` / プリミティブ型(str, int, float, bool): そのまま返す
|
|
53
|
+
- ``dict``: 値を再帰的に処理した新しい dict を返す
|
|
54
|
+
- ``list``: 要素を再帰的に処理した新しい list を返す
|
|
55
|
+
- その他のオブジェクト: ``_AsDict`` でラップして返す
|
|
56
|
+
"""
|
|
57
|
+
if data is None or isinstance(data, (str, int, float, bool, date, datetime, Decimal)):
|
|
58
|
+
return data
|
|
59
|
+
if isinstance(data, dict):
|
|
60
|
+
return {k: _wrap(v) for k, v in data.items()}
|
|
61
|
+
if isinstance(data, list):
|
|
62
|
+
return [_wrap(item) for item in data]
|
|
63
|
+
return _AsDict(data)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_JMESPATH_TYPE_MAP: dict[type, str] = {
|
|
67
|
+
str: "string",
|
|
68
|
+
int: "number",
|
|
69
|
+
float: "number",
|
|
70
|
+
bool: "boolean",
|
|
71
|
+
dict: "object",
|
|
72
|
+
list: "array",
|
|
73
|
+
type(None): "null",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _hint_to_jmespath_spec(hint: Any) -> dict:
|
|
78
|
+
"""Python 型ヒントを jmespath の引数型仕様 dict に変換する。
|
|
79
|
+
|
|
80
|
+
- str / int / float / bool / dict / list / None → 対応する jmespath type
|
|
81
|
+
- Union[X, Y] / X | Y → 複数型のリスト
|
|
82
|
+
- Any・未対応型 → {"types": []}(型チェック無効)
|
|
83
|
+
"""
|
|
84
|
+
if hint is Any:
|
|
85
|
+
return {"types": []}
|
|
86
|
+
|
|
87
|
+
origin = get_origin(hint)
|
|
88
|
+
is_union = origin is Union
|
|
89
|
+
if not is_union and hasattr(_builtins, "UnionType"):
|
|
90
|
+
is_union = isinstance(hint, _builtins.UnionType)
|
|
91
|
+
|
|
92
|
+
if is_union:
|
|
93
|
+
jmespath_types = []
|
|
94
|
+
for arg in get_args(hint):
|
|
95
|
+
jt = _JMESPATH_TYPE_MAP.get(arg)
|
|
96
|
+
if jt is None:
|
|
97
|
+
return {"types": []} # 不明な型が含まれる場合は無制限
|
|
98
|
+
jmespath_types.append(jt)
|
|
99
|
+
return {"types": jmespath_types}
|
|
100
|
+
|
|
101
|
+
jt = _JMESPATH_TYPE_MAP.get(hint)
|
|
102
|
+
return {"types": [jt]} if jt is not None else {"types": []}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _auto_signature(cls: type) -> type:
|
|
106
|
+
"""``_func_*`` メソッドの型ヒントから signature を自動生成するクラスデコレータ(内部用)。
|
|
107
|
+
|
|
108
|
+
クラス自身に定義された ``_func_*`` メソッドのうち、まだ ``.signature`` 属性を持たないものに対して
|
|
109
|
+
引数の型ヒントから ``_hint_to_jmespath_spec()`` で仕様を生成し付与する。
|
|
110
|
+
"""
|
|
111
|
+
for name, method in vars(cls).items():
|
|
112
|
+
if not name.startswith("_func_") or not callable(method) or hasattr(method, "signature"):
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
hints = get_type_hints(method)
|
|
116
|
+
except (NameError, TypeError):
|
|
117
|
+
hints = {}
|
|
118
|
+
params = [p for p in inspect.signature(method).parameters if p != "self"]
|
|
119
|
+
method.signature = tuple(_hint_to_jmespath_spec(hints.get(p, Any)) for p in params)
|
|
120
|
+
return cls
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@_auto_signature
|
|
124
|
+
class DefaultFunctions(functions.Functions):
|
|
125
|
+
"""transform() の全呼び出しで自動的に有効になるデフォルトカスタム関数。
|
|
126
|
+
|
|
127
|
+
アドオンクラスからも継承して拡張できる。
|
|
128
|
+
``@_auto_signature`` により ``_func_*`` メソッドの型ヒントから signature が自動生成される。
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# --- 日付系 ---
|
|
132
|
+
def _func_format_date(self, d: date | datetime | str, fmt: str) -> str:
|
|
133
|
+
"""date/datetime オブジェクト、または ISO 8601 文字列を指定フォーマットで文字列化する。
|
|
134
|
+
|
|
135
|
+
文字列を渡した場合は ISO 8601 として解釈してから変換する(date/datetime を経由し、
|
|
136
|
+
出力に Python 型が残らないようにするため、この関数だけで完結させる設計)。
|
|
137
|
+
|
|
138
|
+
例: format_date(ordered_at, '%Y%m') -> '202503'
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(d, str):
|
|
141
|
+
d = datetime.fromisoformat(d)
|
|
142
|
+
return d.strftime(fmt)
|
|
143
|
+
|
|
144
|
+
# --- 文字列系 ---
|
|
145
|
+
def _func_upper(self, s: str) -> str:
|
|
146
|
+
"""文字列を大文字に変換する。"""
|
|
147
|
+
return s.upper()
|
|
148
|
+
|
|
149
|
+
def _func_lower(self, s: str) -> str:
|
|
150
|
+
"""文字列を小文字に変換する。"""
|
|
151
|
+
return s.lower()
|
|
152
|
+
|
|
153
|
+
def _func_strip(self, s: str | None) -> str:
|
|
154
|
+
"""文字列の前後の空白を除去する。null は空文字として扱う。"""
|
|
155
|
+
return (s or "").strip()
|
|
156
|
+
|
|
157
|
+
def _func_replace(self, s: str | None, old: str, new: str) -> str:
|
|
158
|
+
"""文字列中の old を new に置換する。null は空文字として扱う。"""
|
|
159
|
+
return (s or "").replace(old, new)
|
|
160
|
+
|
|
161
|
+
def _func_split(self, s: str, sep: str) -> list[str]:
|
|
162
|
+
"""文字列を sep で分割してリストに変換する。"""
|
|
163
|
+
return s.split(sep)
|
|
164
|
+
|
|
165
|
+
# --- 値マッピング系 ---
|
|
166
|
+
def _func_match(self, value: Any, mapping: dict, default: Any) -> Any:
|
|
167
|
+
"""value を mapping の key と照合し対応する値を返す。一致しなければ default を返す。"""
|
|
168
|
+
return mapping.get(value, default)
|
|
169
|
+
|
|
170
|
+
def _func_coalesce(self, *values: Any) -> Any:
|
|
171
|
+
"""先頭から順に null でない最初の値を返す(SQL の COALESCE 相当)。すべて null なら null。
|
|
172
|
+
|
|
173
|
+
``||`` は JMESPath の falsy(``false``/``null``/``""``/``[]``/``{}``)でフォールバックするが、
|
|
174
|
+
こちらは ``null`` のみを対象にするため、``0`` や ``false``、``""`` を正当な値として残せる。
|
|
175
|
+
"""
|
|
176
|
+
return next((v for v in values if v is not None), None)
|
|
177
|
+
|
|
178
|
+
_func_coalesce.signature = ({"types": [], "variadic": True},)
|
|
179
|
+
|
|
180
|
+
# --- JSON 変換 ---
|
|
181
|
+
def _func_to_json_string(self, data: Any) -> str:
|
|
182
|
+
"""任意のデータを JSON 文字列へ変換する。ensure_ascii=False で日本語はそのまま出力。"""
|
|
183
|
+
return json.dumps(data, ensure_ascii=False)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@lru_cache(maxsize=1024)
|
|
187
|
+
def _func_to_addon_class(fn: Callable) -> type:
|
|
188
|
+
"""型ヒント付き関数を jmespath アドオンクラスに変換する(結果はキャッシュされる)。
|
|
189
|
+
|
|
190
|
+
各引数の型ヒントから ``@functions.signature()`` 相当の宣言を自動生成する。
|
|
191
|
+
型ヒントのない引数・未対応型は ``{"types": []}``(型チェック無効)として扱う。
|
|
192
|
+
``self`` 引数は不要(通常の関数として定義すればよい)。
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
fn: 型ヒント付きの関数
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
``functions.Functions`` のサブクラス
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
hints = get_type_hints(fn)
|
|
202
|
+
except (NameError, TypeError):
|
|
203
|
+
hints = {}
|
|
204
|
+
|
|
205
|
+
params = list(inspect.signature(fn).parameters.keys())
|
|
206
|
+
specs = [_hint_to_jmespath_spec(hints.get(p, Any)) for p in params]
|
|
207
|
+
|
|
208
|
+
def _method(self, *args):
|
|
209
|
+
return fn(*args)
|
|
210
|
+
|
|
211
|
+
_method.signature = tuple(specs) # functions.signature() 相当
|
|
212
|
+
return type(f"_Addon_{fn.__name__}", (functions.Functions,), {
|
|
213
|
+
f"_func_{fn.__name__}": _method,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@lru_cache(maxsize=4096)
|
|
218
|
+
def _compile(expression: str) -> jmespath.parser.ParsedResult:
|
|
219
|
+
"""JMESPath 式をコンパイルする(結果はキャッシュされる)。"""
|
|
220
|
+
return jmespath.compile(expression)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@lru_cache(maxsize=1024)
|
|
224
|
+
def _make_options(*addon_classes: type) -> jmespath.Options:
|
|
225
|
+
"""アドオン関数クラスを DefaultFunctions と合成して Options を生成する(結果はキャッシュされる)。
|
|
226
|
+
|
|
227
|
+
``DefaultFunctions`` は常に末尾に含まれるため、アドオンの有無にかかわらず
|
|
228
|
+
デフォルト関数(format_date, upper, lower, strip, replace, split, match, coalesce, to_json_string)が利用できる。
|
|
229
|
+
複数アドオンは多重継承で合成されるため、同じ組み合わせは常に同一インスタンスを再利用する。
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
*addon_classes: ``jmespath.functions.Functions`` のサブクラス群
|
|
233
|
+
"""
|
|
234
|
+
combined = type("_CombinedFunctions", (*addon_classes, DefaultFunctions), {})
|
|
235
|
+
return jmespath.Options(custom_functions=combined())
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def transform(expression: str, data: Any, *addon_funcs: Callable) -> Any:
|
|
239
|
+
"""JMESPath 式を評価する。
|
|
240
|
+
|
|
241
|
+
任意のオブジェクトは ``_wrap()`` によって自動的に dict 風にラップされる。
|
|
242
|
+
``DefaultFunctions`` は常に有効なので、アドオンなしでも
|
|
243
|
+
format_date / upper / lower / strip / replace / split / match / coalesce / to_json_string が使える。
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
expression: JMESPath 式文字列
|
|
247
|
+
data: 評価対象のデータ(dict, list, または任意のオブジェクト)
|
|
248
|
+
*addon_funcs: 追加のカスタム関数(型ヒント付き)。
|
|
249
|
+
引数の型ヒントから ``@functions.signature()`` 相当の宣言を自動生成する。
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
JMESPath 評価結果
|
|
253
|
+
|
|
254
|
+
See:
|
|
255
|
+
https://jmespath.org/
|
|
256
|
+
|
|
257
|
+
Examples:
|
|
258
|
+
デフォルト関数なしで format_date や upper が利用できる::
|
|
259
|
+
|
|
260
|
+
result = transform("upper(name)", {"name": "alice"})
|
|
261
|
+
result = transform("{d: format_date(date_str, '%Y%m')}", {"date_str": "2025-03-01"})
|
|
262
|
+
|
|
263
|
+
オブジェクトを自動ラップ(明示的な AsDict 不要)::
|
|
264
|
+
|
|
265
|
+
result = transform("{message: err.message}", {"err": some_exception})
|
|
266
|
+
|
|
267
|
+
型ヒント付き関数を渡す::
|
|
268
|
+
|
|
269
|
+
def my_func(s: str) -> str:
|
|
270
|
+
return s[::-1] # 逆字
|
|
271
|
+
|
|
272
|
+
result = transform("my_func(name)", data, my_func)
|
|
273
|
+
|
|
274
|
+
複数の関数を渡す::
|
|
275
|
+
|
|
276
|
+
result = transform("...", data, fn_a, fn_b)
|
|
277
|
+
"""
|
|
278
|
+
addons = tuple(_func_to_addon_class(fn) for fn in addon_funcs)
|
|
279
|
+
result = _compile(expression).search(_wrap(data), options=_make_options(*addons))
|
|
280
|
+
logger.debug("transform: %s", result)
|
|
281
|
+
return result
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""``.roadmap.md`` パーサー兼 Dynamic Runner(Phase 1)。
|
|
2
|
+
|
|
3
|
+
``docs/dev/DESIGN.md`` で定義された `.roadmap.md` フォーマットを読み込み、
|
|
4
|
+
``RoadmapDocument`` に構造化する。パイプラインの実行(``pipeline().transform()``)と
|
|
5
|
+
ドキュメントに書かれた IN/OUT の自己検証(``verify_all()``)を提供する。
|
|
6
|
+
|
|
7
|
+
Markdown 自体は「見出し(レベル不問)+フェンス付きコードブロックの出現順」という
|
|
8
|
+
限定的な構造しか使わないため、汎用 Markdown パーサーには依存せず、行走査の専用
|
|
9
|
+
パーサーを自作している。見出しの階層構造(セクション分けの深さ)は利用者に委ねられて
|
|
10
|
+
おり、`@jmesway` マーカーが付いている見出しだけが Case として扱われる。マーカーの
|
|
11
|
+
無い見出しは、配下に何が書かれていても(草稿や過去の例を含め)完全に無視される。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import json5
|
|
22
|
+
|
|
23
|
+
from .jmespath_transform import transform
|
|
24
|
+
|
|
25
|
+
_HEADING_RE = re.compile(r"^#{1,6}\s+(.+?)(?:\s+(@jmesway)(?:\s+#(\w+))?)?\s*$")
|
|
26
|
+
_FENCE_START_RE = re.compile(r"^```(\w+)\s*$")
|
|
27
|
+
_FENCE_END_RE = re.compile(r"^```\s*$")
|
|
28
|
+
_REF_LINK_RE = re.compile(r"^\[([^\]]+)\]\(#([^)]+)\)\s*$")
|
|
29
|
+
|
|
30
|
+
# _scan_elements() が返す1要素: (種別, 値, 行番号(1-based))
|
|
31
|
+
# 種別は "json5"(値はパース済みデータ)/ "jmespath"(値は式文字列)/ "ref"(値は参照先パイプライン名)
|
|
32
|
+
_Element = tuple[str, Any, int]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Pipeline:
|
|
37
|
+
"""タグ付き Case から生成される、``|`` 連結済みの JMESPath 式。"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
expression: str
|
|
41
|
+
|
|
42
|
+
def transform(self, data: Any) -> Any:
|
|
43
|
+
return transform(self.expression, data)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Step:
|
|
48
|
+
"""Case 内の1段階(JMESPath 式と、それを適用した直後に期待される json5 値)。"""
|
|
49
|
+
|
|
50
|
+
expression: str
|
|
51
|
+
expected: Any
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CaseVerificationError(AssertionError):
|
|
55
|
+
"""``Case.verify()`` の失敗。どの Case / どの JMESPath 式で失敗したかを属性として保持する。"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, case: Case, step_index: int, expression: str, expected: Any, actual: Any) -> None:
|
|
58
|
+
self.case = case
|
|
59
|
+
self.step_index = step_index
|
|
60
|
+
self.expression = expression
|
|
61
|
+
self.expected = expected
|
|
62
|
+
self.actual = actual
|
|
63
|
+
super().__init__(
|
|
64
|
+
f"{case.title} (line {case.line}): step {step_index} mismatch\n"
|
|
65
|
+
f" expression: {expression}\n"
|
|
66
|
+
f" expected: {expected!r}\n"
|
|
67
|
+
f" actual: {actual!r}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class Case:
|
|
73
|
+
"""``###`` 見出し1つ分(IN → Step の交互列)。"""
|
|
74
|
+
|
|
75
|
+
title: str
|
|
76
|
+
pipeline_name: str | None
|
|
77
|
+
references: str | None
|
|
78
|
+
input: Any
|
|
79
|
+
steps: list[Step]
|
|
80
|
+
line: int
|
|
81
|
+
|
|
82
|
+
def verify(self) -> None:
|
|
83
|
+
"""IN から各 Step を順に適用し、期待値と一致するか検証する。
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
CaseVerificationError: いずれかの段階で実際の結果が期待値と一致しない場合。
|
|
87
|
+
"""
|
|
88
|
+
current = self.input
|
|
89
|
+
for i, step in enumerate(self.steps, start=1):
|
|
90
|
+
actual = transform(step.expression, current)
|
|
91
|
+
if actual != step.expected:
|
|
92
|
+
raise CaseVerificationError(self, i, step.expression, step.expected, actual)
|
|
93
|
+
current = actual
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class RoadmapDocument:
|
|
98
|
+
"""1つの ``.roadmap.md`` ファイルをパースした結果。"""
|
|
99
|
+
|
|
100
|
+
path: Path
|
|
101
|
+
cases: list[Case] = field(default_factory=list)
|
|
102
|
+
pipelines: dict[str, Pipeline] = field(default_factory=dict)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def load(cls, path: str | Path) -> RoadmapDocument:
|
|
106
|
+
path = Path(path)
|
|
107
|
+
return _parse(path, path.read_text(encoding="utf-8"))
|
|
108
|
+
|
|
109
|
+
def pipeline(self, name: str) -> Pipeline:
|
|
110
|
+
try:
|
|
111
|
+
return self.pipelines[name]
|
|
112
|
+
except KeyError:
|
|
113
|
+
raise KeyError(f"pipeline '{name}' is not defined in {self.path}") from None
|
|
114
|
+
|
|
115
|
+
def pipeline_cases(self, name: str) -> list[Case]:
|
|
116
|
+
"""指定パイプラインを定義または参照している Case を出現順に返す。
|
|
117
|
+
|
|
118
|
+
build 済みの `.py` 関数を、roadmap.md の Case データでパラメタライズしてテストする
|
|
119
|
+
(利用者が自分のテストコードから呼ぶ)ためのヘルパー。
|
|
120
|
+
"""
|
|
121
|
+
return [c for c in self.cases if c.pipeline_name == name or c.references == name]
|
|
122
|
+
|
|
123
|
+
def verify_all(self) -> None:
|
|
124
|
+
for case in self.cases:
|
|
125
|
+
case.verify()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _scan_elements(lines: list[str], start: int, end: int) -> list[_Element]:
|
|
129
|
+
"""[start, end) の行範囲から、フェンス付きコードブロックとパイプライン参照リンクを出現順に抽出する。"""
|
|
130
|
+
elements: list[_Element] = []
|
|
131
|
+
i = start
|
|
132
|
+
while i < end:
|
|
133
|
+
line = lines[i]
|
|
134
|
+
fence_match = _FENCE_START_RE.match(line)
|
|
135
|
+
if fence_match:
|
|
136
|
+
lang = fence_match.group(1)
|
|
137
|
+
body_start = i + 1
|
|
138
|
+
j = body_start
|
|
139
|
+
while j < end and not _FENCE_END_RE.match(lines[j]):
|
|
140
|
+
j += 1
|
|
141
|
+
body = "\n".join(lines[body_start:j])
|
|
142
|
+
if lang == "json5":
|
|
143
|
+
elements.append(("json5", json5.loads(body), i + 1))
|
|
144
|
+
elif lang == "jmespath":
|
|
145
|
+
elements.append(("jmespath", body.strip(), i + 1))
|
|
146
|
+
i = j + 1
|
|
147
|
+
continue
|
|
148
|
+
ref_match = _REF_LINK_RE.match(line.strip())
|
|
149
|
+
if ref_match:
|
|
150
|
+
elements.append(("ref", ref_match.group(2), i + 1))
|
|
151
|
+
i += 1
|
|
152
|
+
return elements
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _build_case(
|
|
156
|
+
path: Path,
|
|
157
|
+
title: str,
|
|
158
|
+
elements: list[_Element],
|
|
159
|
+
pipelines: dict[str, Pipeline],
|
|
160
|
+
line: int,
|
|
161
|
+
) -> Case:
|
|
162
|
+
if not elements or elements[0][0] != "json5":
|
|
163
|
+
raise ValueError(f"{path}:{line}: case '{title}' must start with a json5 block (IN)")
|
|
164
|
+
if elements[-1][0] != "json5":
|
|
165
|
+
raise ValueError(f"{path}:{line}: case '{title}' must end with a json5 block (OUT)")
|
|
166
|
+
|
|
167
|
+
input_value = elements[0][1]
|
|
168
|
+
steps: list[Step] = []
|
|
169
|
+
references: str | None = None
|
|
170
|
+
i = 1
|
|
171
|
+
while i < len(elements):
|
|
172
|
+
if elements[i][0] == "json5":
|
|
173
|
+
raise ValueError(
|
|
174
|
+
f"{path}:{elements[i][2]}: case '{title}' has two consecutive json5 blocks; "
|
|
175
|
+
"expected a jmespath block or pipeline reference in between"
|
|
176
|
+
)
|
|
177
|
+
# 次の json5 が現れるまでの連続する jmespath/ref を `|` で連結し、1つの Step にまとめる。
|
|
178
|
+
# 中間の期待値を都度書かなくても、複数パイプラインの合成(mix)を1つの Step として書ける。
|
|
179
|
+
expr_parts: list[str] = []
|
|
180
|
+
j = i
|
|
181
|
+
while j < len(elements) and elements[j][0] != "json5":
|
|
182
|
+
kind, value, elem_line = elements[j]
|
|
183
|
+
if kind == "jmespath":
|
|
184
|
+
expr_parts.append(value)
|
|
185
|
+
else: # "ref"
|
|
186
|
+
if value not in pipelines:
|
|
187
|
+
raise ValueError(f"{path}:{elem_line}: unknown pipeline reference '#{value}'")
|
|
188
|
+
expr_parts.append(pipelines[value].expression)
|
|
189
|
+
if references is None:
|
|
190
|
+
references = value
|
|
191
|
+
j += 1
|
|
192
|
+
|
|
193
|
+
if j >= len(elements):
|
|
194
|
+
kind_label = "jmespath expression" if elements[j - 1][0] == "jmespath" else "pipeline reference"
|
|
195
|
+
raise ValueError(f"{path}:{elements[j - 1][2]}: case '{title}' expects a json5 block after {kind_label}")
|
|
196
|
+
expected = elements[j][1]
|
|
197
|
+
steps.append(Step(expression=" | ".join(expr_parts), expected=expected))
|
|
198
|
+
i = j + 1
|
|
199
|
+
|
|
200
|
+
return Case(
|
|
201
|
+
title=title,
|
|
202
|
+
pipeline_name=None,
|
|
203
|
+
references=references,
|
|
204
|
+
input=input_value,
|
|
205
|
+
steps=steps,
|
|
206
|
+
line=line,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _find_headings(lines: list[str]) -> list[tuple[int, str, bool, str | None]]:
|
|
212
|
+
"""フェンス外の見出し行を (0-based行番号, タイトル, @jmeswayマーカーの有無, タグ) のリストとして返す。
|
|
213
|
+
|
|
214
|
+
見出しレベル(`#`の個数)は区別しない。`@jmesway` が付いた見出しだけが Case 候補となり、
|
|
215
|
+
それ以外(タグやコードブロックがあっても)は完全に無視される。
|
|
216
|
+
"""
|
|
217
|
+
headings: list[tuple[int, str, bool, str | None]] = []
|
|
218
|
+
in_fence = False
|
|
219
|
+
for idx, line in enumerate(lines):
|
|
220
|
+
if in_fence:
|
|
221
|
+
if _FENCE_END_RE.match(line):
|
|
222
|
+
in_fence = False
|
|
223
|
+
continue
|
|
224
|
+
if _FENCE_START_RE.match(line):
|
|
225
|
+
in_fence = True
|
|
226
|
+
continue
|
|
227
|
+
heading_match = _HEADING_RE.match(line)
|
|
228
|
+
if heading_match:
|
|
229
|
+
marker = heading_match.group(2) is not None
|
|
230
|
+
headings.append((idx, heading_match.group(1), marker, heading_match.group(3)))
|
|
231
|
+
return headings
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _parse(path: Path, text: str) -> RoadmapDocument:
|
|
235
|
+
lines = text.splitlines()
|
|
236
|
+
n = len(lines)
|
|
237
|
+
headings = _find_headings(lines)
|
|
238
|
+
|
|
239
|
+
# `@jmesway` が付いた見出しだけを Case 候補として集める。マーカーの無い見出しは
|
|
240
|
+
# 目次・前書き・草稿・過去の例など、自由な地の文として完全に無視する。
|
|
241
|
+
raw_sections: list[tuple[str | None, str, int, list[_Element]]] = []
|
|
242
|
+
seen_tags: set[str] = set()
|
|
243
|
+
for i, (idx, title, marker, tag) in enumerate(headings):
|
|
244
|
+
if not marker:
|
|
245
|
+
continue
|
|
246
|
+
content_start = idx + 1
|
|
247
|
+
content_end = headings[i + 1][0] if i + 1 < len(headings) else n
|
|
248
|
+
elements = _scan_elements(lines, content_start, content_end)
|
|
249
|
+
heading_line = idx + 1
|
|
250
|
+
if tag:
|
|
251
|
+
if tag in seen_tags:
|
|
252
|
+
raise ValueError(f"{path}:{heading_line}: duplicate pipeline tag '#{tag}' (case '{title}')")
|
|
253
|
+
seen_tags.add(tag)
|
|
254
|
+
raw_sections.append((tag, title, heading_line, elements))
|
|
255
|
+
|
|
256
|
+
tagged_sections = {
|
|
257
|
+
tag: (elements, heading_line, title) for tag, title, heading_line, elements in raw_sections if tag
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
# パイプラインは、ファイル内のどこに定義されていても(前方参照でも)解決できるよう、
|
|
261
|
+
# Case を組み立てる前に一括で解決しておく。
|
|
262
|
+
pipelines: dict[str, Pipeline] = {}
|
|
263
|
+
resolving: set[str] = set()
|
|
264
|
+
|
|
265
|
+
def resolve_pipeline(name: str) -> Pipeline:
|
|
266
|
+
if name in pipelines:
|
|
267
|
+
return pipelines[name]
|
|
268
|
+
if name not in tagged_sections:
|
|
269
|
+
raise ValueError(f"{path}: unknown pipeline reference '#{name}'")
|
|
270
|
+
if name in resolving:
|
|
271
|
+
raise ValueError(f"{path}: circular pipeline reference involving '#{name}'")
|
|
272
|
+
resolving.add(name)
|
|
273
|
+
elements, heading_line, title = tagged_sections[name]
|
|
274
|
+
parts = [
|
|
275
|
+
resolve_pipeline(value).expression if kind == "ref" else value
|
|
276
|
+
for kind, value, _line in elements
|
|
277
|
+
if kind in ("jmespath", "ref")
|
|
278
|
+
]
|
|
279
|
+
resolving.discard(name)
|
|
280
|
+
if not parts:
|
|
281
|
+
raise ValueError(
|
|
282
|
+
f"{path}:{heading_line}: pipeline '#{name}' (case '{title}') has no jmespath expression"
|
|
283
|
+
)
|
|
284
|
+
pipeline = Pipeline(name=name, expression=" | ".join(parts))
|
|
285
|
+
pipelines[name] = pipeline
|
|
286
|
+
return pipeline
|
|
287
|
+
|
|
288
|
+
for tag in tagged_sections:
|
|
289
|
+
resolve_pipeline(tag)
|
|
290
|
+
|
|
291
|
+
cases: list[Case] = []
|
|
292
|
+
for tag, title, heading_line, elements in raw_sections:
|
|
293
|
+
case = _build_case(path, title, elements, pipelines, heading_line)
|
|
294
|
+
if tag:
|
|
295
|
+
case.pipeline_name = tag
|
|
296
|
+
cases.append(case)
|
|
297
|
+
|
|
298
|
+
return RoadmapDocument(path=path, cases=cases, pipelines=pipelines)
|
|
299
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""``.roadmap.md`` から事前コンパイル済み `.py` を生成するトランスパイラ(Phase 3)。
|
|
2
|
+
|
|
3
|
+
生成された関数は ``jmesway.jmespath_transform.transform()`` を実行基盤として使うため、
|
|
4
|
+
``upper`` / ``match`` などの独自拡張関数もそのまま利用できる。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .roadmap import Case, RoadmapDocument
|
|
13
|
+
|
|
14
|
+
_HEADER_TEMPLATE = '''"""generated by `jmesway build` from {source}. DO NOT EDIT."""
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from jmesway.jmespath_transform import transform
|
|
19
|
+
'''
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def output_path_for(source_path: Path) -> Path:
|
|
23
|
+
"""``<name>.roadmap.md`` に対応する生成先 ``<name>.py`` のパスを返す。"""
|
|
24
|
+
name = source_path.name
|
|
25
|
+
suffix = ".roadmap.md"
|
|
26
|
+
stem = name[: -len(suffix)] if name.endswith(suffix) else source_path.stem
|
|
27
|
+
return source_path.with_name(f"{stem}.py")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _render_function(name: str, expression: str, case: Case | None) -> str:
|
|
31
|
+
const_name = name.upper()
|
|
32
|
+
if case is not None:
|
|
33
|
+
sample = (
|
|
34
|
+
f"\n\n IN: {json.dumps(case.input, ensure_ascii=False)}"
|
|
35
|
+
f"\n OUT: {json.dumps(case.steps[-1].expected, ensure_ascii=False)}"
|
|
36
|
+
)
|
|
37
|
+
summary = case.title
|
|
38
|
+
else:
|
|
39
|
+
summary = name
|
|
40
|
+
sample = ""
|
|
41
|
+
return (
|
|
42
|
+
f"{const_name} = {expression!r}\n\n\n"
|
|
43
|
+
f"def {name}(data: Any) -> Any:\n"
|
|
44
|
+
f' """{summary}{sample}\n """\n'
|
|
45
|
+
f" return transform({const_name}, data)\n"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_source(doc: RoadmapDocument, source_path: Path) -> str:
|
|
50
|
+
"""``doc`` の全パイプラインから、事前コンパイル済みの `.py` ソースコードを生成する。"""
|
|
51
|
+
case_by_pipeline = {case.pipeline_name: case for case in doc.cases if case.pipeline_name}
|
|
52
|
+
|
|
53
|
+
parts = [_HEADER_TEMPLATE.format(source=source_path.as_posix())]
|
|
54
|
+
for name, pipeline in doc.pipelines.items():
|
|
55
|
+
parts.append(_render_function(name, pipeline.expression, case_by_pipeline.get(name)))
|
|
56
|
+
|
|
57
|
+
return "\n".join(parts).rstrip() + "\n"
|
|
58
|
+
|