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 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
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](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
+