papycli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
papycli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """papycli — OpenAPI 3.0 REST API を操作する CLI ツール."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("papycli")
papycli/api_call.py ADDED
@@ -0,0 +1,192 @@
1
+ """HTTP リクエスト実行・パステンプレートマッチング・パラメータ構築."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from collections.abc import Sequence
7
+ from typing import Any
8
+
9
+ import requests
10
+
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # パステンプレートマッチング
14
+ # ---------------------------------------------------------------------------
15
+
16
+
17
+ def _template_to_regex(template: str) -> tuple[str, list[str]]:
18
+ """/pet/{petId} → (r'/pet/([^/]+)', ['petId']) に変換する。"""
19
+ param_names: list[str] = []
20
+ parts = re.split(r"\{([^}]+)\}", template)
21
+ pattern_parts: list[str] = []
22
+ for i, part in enumerate(parts):
23
+ if i % 2 == 0:
24
+ pattern_parts.append(re.escape(part))
25
+ else:
26
+ param_names.append(part)
27
+ pattern_parts.append("([^/]+)")
28
+ return "".join(pattern_parts), param_names
29
+
30
+
31
+ def match_path_template(
32
+ resource: str, templates: list[str]
33
+ ) -> tuple[str, dict[str, str]] | None:
34
+ """resource をテンプレート一覧にマッチさせる。
35
+
36
+ 完全一致を優先し、次にテンプレート変数が少ない(具体的な)順。
37
+ マッチしない場合は None を返す。
38
+ """
39
+ if resource in templates:
40
+ return resource, {}
41
+
42
+ matches: list[tuple[str, dict[str, str], int]] = []
43
+ for template in templates:
44
+ pattern, param_names = _template_to_regex(template)
45
+ m = re.fullmatch(pattern, resource)
46
+ if m:
47
+ path_params = dict(zip(param_names, m.groups()))
48
+ matches.append((template, path_params, len(param_names)))
49
+
50
+ if not matches:
51
+ return None
52
+
53
+ matches.sort(key=lambda x: x[2])
54
+ template, path_params, _ = matches[0]
55
+ return template, path_params
56
+
57
+
58
+ def expand_path(template: str, path_params: dict[str, str]) -> str:
59
+ """/pet/{petId} + {petId: '99'} → /pet/99"""
60
+ result = template
61
+ for name, value in path_params.items():
62
+ result = result.replace(f"{{{name}}}", value)
63
+ return result
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # パラメータ構築
68
+ # ---------------------------------------------------------------------------
69
+
70
+
71
+ def _set_or_append(obj: dict[str, Any], key: str, value: str) -> None:
72
+ """dict にキーが既存なら配列に、なければ単値として設定する。"""
73
+ if key not in obj:
74
+ obj[key] = value
75
+ elif isinstance(obj[key], list):
76
+ obj[key].append(value)
77
+ else:
78
+ obj[key] = [obj[key], value]
79
+
80
+
81
+ def build_body(pairs: Sequence[tuple[str, str]]) -> dict[str, Any]:
82
+ """(-p name value) ペアから JSON ボディ dict を構築する。
83
+
84
+ - 同じキーを繰り返すと JSON 配列になる
85
+ - ドット記法 (category.id) で 1 レベルのネストオブジェクトになる
86
+ """
87
+ result: dict[str, Any] = {}
88
+ for name, value in pairs:
89
+ if "." in name:
90
+ parent, child = name.split(".", 1)
91
+ if parent not in result:
92
+ result[parent] = {}
93
+ parent_obj = result[parent]
94
+ if not isinstance(parent_obj, dict):
95
+ raise ValueError(
96
+ f"Cannot use dot notation on '{parent}': already a scalar or array"
97
+ )
98
+ _set_or_append(parent_obj, child, value)
99
+ else:
100
+ _set_or_append(result, name, value)
101
+ return result
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # ヘッダー解析
106
+ # ---------------------------------------------------------------------------
107
+
108
+
109
+ def parse_headers(
110
+ header_strings: Sequence[str],
111
+ custom_header_env: str | None = None,
112
+ ) -> dict[str, str]:
113
+ """"-H Header: Value" 文字列と PAPYCLI_CUSTOM_HEADER 環境変数からヘッダー dict を構築する。
114
+
115
+ 環境変数より -H オプションが優先される。
116
+ """
117
+ headers: dict[str, str] = {}
118
+
119
+ if custom_header_env:
120
+ for line in custom_header_env.splitlines():
121
+ line = line.strip()
122
+ if line and ":" in line:
123
+ k, _, v = line.partition(":")
124
+ headers[k.strip()] = v.strip()
125
+
126
+ for h in header_strings:
127
+ if ":" not in h:
128
+ raise ValueError(f"Invalid header format: {h!r} (expected 'Name: Value')")
129
+ k, _, v = h.partition(":")
130
+ headers[k.strip()] = v.strip()
131
+
132
+ return headers
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # HTTP 実行
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def call_api(
141
+ method: str,
142
+ resource: str,
143
+ base_url: str,
144
+ apidef: dict[str, Any],
145
+ *,
146
+ query_params: Sequence[tuple[str, str]] = (),
147
+ body_params: Sequence[tuple[str, str]] = (),
148
+ raw_body: str | None = None,
149
+ extra_headers: Sequence[str] = (),
150
+ ) -> requests.Response:
151
+ """API を呼び出し、レスポンスを返す。"""
152
+ if not base_url:
153
+ raise RuntimeError(
154
+ "Base URL is not configured. Edit papycli.conf and set the 'url' field."
155
+ )
156
+
157
+ templates = list(apidef.keys())
158
+ match = match_path_template(resource, templates)
159
+ if match is None:
160
+ raise ValueError(
161
+ f"No matching path for '{resource}'.\n"
162
+ f"Available paths: {', '.join(templates)}"
163
+ )
164
+ template, path_params = match
165
+
166
+ ops: list[dict[str, Any]] = apidef[template]
167
+ if not any(op["method"] == method for op in ops):
168
+ available = [op["method"] for op in ops]
169
+ raise ValueError(
170
+ f"Method '{method}' is not defined for '{template}'. "
171
+ f"Available: {', '.join(available)}"
172
+ )
173
+
174
+ expanded = expand_path(template, path_params)
175
+ url = base_url.rstrip("/") + expanded
176
+
177
+ custom_env = os.environ.get("PAPYCLI_CUSTOM_HEADER")
178
+ headers = parse_headers(extra_headers, custom_env)
179
+
180
+ json_body: dict[str, Any] | None = None
181
+ if raw_body is not None:
182
+ json_body = json.loads(raw_body)
183
+ elif body_params:
184
+ json_body = build_body(body_params)
185
+
186
+ return requests.request(
187
+ method=method.upper(),
188
+ url=url,
189
+ params=list(query_params),
190
+ json=json_body,
191
+ headers=headers,
192
+ )
papycli/completion.py ADDED
@@ -0,0 +1,168 @@
1
+ """シェル補完ロジックとスクリプト生成。
2
+
3
+ 補完の仕組み:
4
+ bash/zsh スクリプトが `papycli _complete <current_index> <words...>` を呼び出す。
5
+ `_complete` コマンドが補完候補を 1 行 1 候補の形式で標準出力に返す。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ METHODS = ["get", "post", "put", "patch", "delete"]
14
+ MANAGEMENT_COMMANDS = ["init", "use", "conf", "summary", "completion-script"]
15
+ ALL_COMMANDS = METHODS + MANAGEMENT_COMMANDS
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # シェルスクリプトテンプレート
19
+ # ---------------------------------------------------------------------------
20
+
21
+ BASH_SCRIPT = """\
22
+ _papycli_completion() {
23
+ local IFS=$'\\n'
24
+ COMPREPLY=($(papycli _complete "${COMP_CWORD}" "${COMP_WORDS[@]}" 2>/dev/null))
25
+ }
26
+ complete -o nospace -F _papycli_completion papycli
27
+ """
28
+
29
+ ZSH_SCRIPT = """\
30
+ _papycli() {
31
+ local -a completions
32
+ completions=(${(f)"$(papycli _complete "$((CURRENT - 1))" "${words[@]}" 2>/dev/null)"})
33
+ if [[ ${#completions[@]} -gt 0 ]]; then
34
+ _describe '' completions
35
+ fi
36
+ }
37
+ compdef _papycli papycli
38
+ """
39
+
40
+
41
+ def generate_script(shell: str) -> str:
42
+ """指定シェル向けの補完スクリプトを返す。"""
43
+ if shell == "bash":
44
+ return BASH_SCRIPT
45
+ return ZSH_SCRIPT
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # 補完ロジック(純粋関数 — apidef を引数で受け取る)
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _find_op(
54
+ apidef: dict[str, Any], method: str, resource: str
55
+ ) -> dict[str, Any] | None:
56
+ """resource にマッチするテンプレートを探し、指定 method の operation を返す。"""
57
+ from papycli.api_call import match_path_template
58
+
59
+ match = match_path_template(resource, list(apidef.keys()))
60
+ if match is None:
61
+ return None
62
+ template, _ = match
63
+ return next((o for o in apidef[template] if o["method"] == method), None)
64
+
65
+
66
+ def _complete_resources(apidef: dict[str, Any], incomplete: str) -> list[str]:
67
+ return [p for p in sorted(apidef.keys()) if p.startswith(incomplete)]
68
+
69
+
70
+ def _complete_param_names(
71
+ apidef: dict[str, Any],
72
+ method: str,
73
+ resource: str,
74
+ kind: str,
75
+ incomplete: str,
76
+ ) -> list[str]:
77
+ op = _find_op(apidef, method, resource)
78
+ if op is None:
79
+ return []
80
+ key = "query_parameters" if kind == "query" else "post_parameters"
81
+ return [p["name"] for p in op.get(key, []) if p["name"].startswith(incomplete)]
82
+
83
+
84
+ def _complete_enum_values(
85
+ apidef: dict[str, Any],
86
+ method: str,
87
+ resource: str,
88
+ kind: str,
89
+ param_name: str,
90
+ incomplete: str,
91
+ ) -> list[str]:
92
+ op = _find_op(apidef, method, resource)
93
+ if op is None:
94
+ return []
95
+ key = "query_parameters" if kind == "query" else "post_parameters"
96
+ for p in op.get(key, []):
97
+ if p["name"] == param_name and "enum" in p:
98
+ return [str(v) for v in p["enum"] if str(v).startswith(incomplete)]
99
+ return []
100
+
101
+
102
+ def completions_for_context(
103
+ words: list[str],
104
+ current: int,
105
+ apidef: dict[str, Any] | None,
106
+ ) -> list[str]:
107
+ """コマンドラインのコンテキストに応じた補完候補を返す。
108
+
109
+ Args:
110
+ words: コマンドライントークンのリスト(words[0] = "papycli")
111
+ current: 補完中の単語のインデックス(0 始まり)
112
+ apidef: 現在の API 定義 dict。None の場合は空リストを返す。
113
+ """
114
+ incomplete = words[current] if current < len(words) else ""
115
+
116
+ # サブコマンド名の補完
117
+ if current == 1:
118
+ return [c for c in ALL_COMMANDS if c.startswith(incomplete)]
119
+
120
+ # words[1] が HTTP メソッドでない場合は補完なし
121
+ if len(words) < 2 or words[1] not in METHODS:
122
+ return []
123
+
124
+ method = words[1]
125
+
126
+ # リソースパスの補完
127
+ if current == 2:
128
+ if apidef is None:
129
+ return []
130
+ return _complete_resources(apidef, incomplete)
131
+
132
+ resource = words[2] if len(words) > 2 else ""
133
+ prev = words[current - 1] if current >= 1 else ""
134
+ prev_prev = words[current - 2] if current >= 2 else ""
135
+
136
+ if apidef is None:
137
+ return []
138
+
139
+ # -q NAME → クエリパラメータ名
140
+ if prev == "-q":
141
+ return _complete_param_names(apidef, method, resource, "query", incomplete)
142
+
143
+ # -q NAME VALUE → enum 値
144
+ if prev_prev == "-q":
145
+ return _complete_enum_values(apidef, method, resource, "query", prev, incomplete)
146
+
147
+ # -p NAME → ボディパラメータ名
148
+ if prev == "-p":
149
+ return _complete_param_names(apidef, method, resource, "body", incomplete)
150
+
151
+ # -p NAME VALUE → enum 値
152
+ if prev_prev == "-p":
153
+ return _complete_enum_values(apidef, method, resource, "body", prev, incomplete)
154
+
155
+ # オプション名
156
+ opts = ["-q", "-p", "-d", "-H", "--summary"]
157
+ return [o for o in opts if o.startswith(incomplete)]
158
+
159
+
160
+ def get_completions(words: list[str], current: int, conf_dir: Path | None = None) -> list[str]:
161
+ """apidef をディスクから読み込んで補完候補を返す。失敗した場合は空リスト。"""
162
+ from papycli.config import get_conf_dir, load_current_apidef
163
+
164
+ try:
165
+ apidef, _ = load_current_apidef(conf_dir or get_conf_dir())
166
+ except Exception:
167
+ apidef = None
168
+ return completions_for_context(words, current, apidef)
papycli/config.py ADDED
@@ -0,0 +1,93 @@
1
+ """設定ファイル (papycli.conf) の読み書き."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ CONF_FILENAME = "papycli.conf"
9
+ APIS_DIRNAME = "apis"
10
+
11
+
12
+ def get_conf_dir() -> Path:
13
+ """設定ディレクトリを返す。PAPYCLI_CONF_DIR 環境変数 or ~/.papycli"""
14
+ env = os.environ.get("PAPYCLI_CONF_DIR")
15
+ if env:
16
+ return Path(env)
17
+ return Path.home() / ".papycli"
18
+
19
+
20
+ def get_conf_path(conf_dir: Path | None = None) -> Path:
21
+ return (conf_dir or get_conf_dir()) / CONF_FILENAME
22
+
23
+
24
+ def get_apis_dir(conf_dir: Path | None = None) -> Path:
25
+ return (conf_dir or get_conf_dir()) / APIS_DIRNAME
26
+
27
+
28
+ def load_conf(conf_dir: Path | None = None) -> dict[str, Any]:
29
+ """設定ファイルを読み込む。存在しない場合は空の dict を返す。"""
30
+ path = get_conf_path(conf_dir)
31
+ if not path.exists():
32
+ return {}
33
+ with path.open(encoding="utf-8") as f:
34
+ return json.load(f) # type: ignore[no-any-return]
35
+
36
+
37
+ def save_conf(conf: dict[str, Any], conf_dir: Path | None = None) -> None:
38
+ """設定ファイルを保存する。ディレクトリが存在しない場合は作成する。"""
39
+ path = get_conf_path(conf_dir)
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ with path.open("w", encoding="utf-8") as f:
42
+ json.dump(conf, f, indent=2, ensure_ascii=False)
43
+ f.write("\n")
44
+
45
+
46
+ def register_api(
47
+ conf: dict[str, Any],
48
+ name: str,
49
+ openapispec: str,
50
+ apidef: str,
51
+ url: str,
52
+ ) -> None:
53
+ """設定に API エントリを追加・更新する。default が未設定の場合は自動設定する。"""
54
+ conf[name] = {"openapispec": openapispec, "apidef": apidef, "url": url}
55
+ if "default" not in conf:
56
+ conf["default"] = name
57
+
58
+
59
+ def set_default_api(conf: dict[str, Any], name: str) -> None:
60
+ """デフォルト API を変更する。"""
61
+ conf["default"] = name
62
+
63
+
64
+ def get_default_api(conf: dict[str, Any]) -> str | None:
65
+ """現在のデフォルト API 名を返す。未設定の場合は None。"""
66
+ return conf.get("default") # type: ignore[return-value]
67
+
68
+
69
+ def load_current_apidef(conf_dir: Path | None = None) -> tuple[dict[str, Any], str]:
70
+ """現在のデフォルト API の (apidef dict, base_url) を返す。"""
71
+ resolved_dir = conf_dir or get_conf_dir()
72
+ conf = load_conf(resolved_dir)
73
+ api_name = get_default_api(conf)
74
+ if not api_name:
75
+ raise RuntimeError("No default API configured. Run 'papycli init <spec>' first.")
76
+
77
+ api_entry = conf.get(api_name)
78
+ if not isinstance(api_entry, dict):
79
+ raise RuntimeError(f"Invalid configuration for API '{api_name}'.")
80
+
81
+ base_url = str(api_entry.get("url", ""))
82
+ apidef_filename = str(api_entry.get("apidef", f"{api_name}.json"))
83
+ apidef_path = get_apis_dir(resolved_dir) / apidef_filename
84
+
85
+ if not apidef_path.exists():
86
+ raise RuntimeError(
87
+ f"API definition file not found: {apidef_path}\n"
88
+ "Run 'papycli init <spec>' to regenerate it."
89
+ )
90
+
91
+ with apidef_path.open(encoding="utf-8") as f:
92
+ apidef: dict[str, Any] = json.load(f)
93
+ return apidef, base_url
papycli/init_cmd.py ADDED
@@ -0,0 +1,43 @@
1
+ """--init コマンドの処理: OpenAPI spec を内部形式に変換して保存する。"""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from papycli.config import get_apis_dir, register_api
7
+ from papycli.spec_loader import extract_base_url, load_spec, resolve_refs, spec_to_apidef
8
+
9
+
10
+ def init_api(spec_path: Path, conf_dir: Path) -> tuple[str, str]:
11
+ """OpenAPI spec を読み込み、内部形式に変換して保存する。
12
+
13
+ Returns:
14
+ (api_name, base_url) のタプル
15
+ """
16
+ raw_spec = load_spec(spec_path)
17
+ resolved_spec = resolve_refs(raw_spec, raw_spec)
18
+ apidef = spec_to_apidef(resolved_spec)
19
+
20
+ api_name = spec_path.stem
21
+ base_url = extract_base_url(resolved_spec)
22
+
23
+ apis_dir = get_apis_dir(conf_dir)
24
+ apis_dir.mkdir(parents=True, exist_ok=True)
25
+
26
+ apidef_path = apis_dir / f"{api_name}.json"
27
+ with apidef_path.open("w", encoding="utf-8") as f:
28
+ json.dump(apidef, f, indent=2, ensure_ascii=False)
29
+ f.write("\n")
30
+
31
+ return api_name, base_url
32
+
33
+
34
+ def register_initialized_api(
35
+ conf: dict, # type: ignore[type-arg]
36
+ api_name: str,
37
+ spec_path: Path,
38
+ base_url: str,
39
+ ) -> None:
40
+ """init 後に設定ファイルへ API エントリを登録する。"""
41
+ spec_filename = spec_path.name
42
+ apidef_filename = f"{api_name}.json"
43
+ register_api(conf, api_name, spec_filename, apidef_filename, base_url)
papycli/main.py ADDED
@@ -0,0 +1,231 @@
1
+ """CLI エントリポイント."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import requests
9
+
10
+ from papycli import __version__
11
+ from papycli.api_call import call_api, match_path_template
12
+ from papycli.config import (
13
+ get_conf_dir,
14
+ get_conf_path,
15
+ load_conf,
16
+ load_current_apidef,
17
+ save_conf,
18
+ set_default_api,
19
+ )
20
+ from papycli.completion import generate_script, get_completions
21
+ from papycli.init_cmd import init_api, register_initialized_api
22
+ from papycli.summary import format_endpoint_detail, format_summary_csv, print_summary
23
+
24
+
25
+ @click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
26
+ @click.version_option(__version__, "-V", "--version")
27
+ @click.pass_context
28
+ def cli(ctx: click.Context) -> None:
29
+ """papycli — OpenAPI 3.0 仕様から REST API を呼び出す CLI ツール."""
30
+ if ctx.invoked_subcommand is None:
31
+ click.echo(ctx.get_help())
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # 設定系コマンド
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @cli.command("init")
40
+ @click.argument("spec_file", metavar="SPEC_FILE", type=click.Path(exists=True, dir_okay=False))
41
+ def cmd_init(spec_file: str) -> None:
42
+ """OpenAPI spec ファイルから API を初期化する。"""
43
+ spec_path = Path(spec_file)
44
+ conf_dir = get_conf_dir()
45
+
46
+ try:
47
+ api_name, base_url = init_api(spec_path, conf_dir)
48
+ except Exception as e:
49
+ click.echo(f"Error: {e}", err=True)
50
+ sys.exit(1)
51
+
52
+ conf = load_conf(conf_dir)
53
+ register_initialized_api(conf, api_name, spec_path, base_url)
54
+ save_conf(conf, conf_dir)
55
+
56
+ click.echo(f"Initialized API '{api_name}'")
57
+ if base_url:
58
+ click.echo(f" Base URL : {base_url}")
59
+ else:
60
+ click.echo(" Base URL : (not set — edit papycli.conf to add url)")
61
+ click.echo(f" Conf dir : {conf_dir}")
62
+
63
+
64
+ @cli.command("use")
65
+ @click.argument("api_name", metavar="API_NAME")
66
+ def cmd_use(api_name: str) -> None:
67
+ """アクティブな API を切り替える。"""
68
+ conf_dir = get_conf_dir()
69
+ conf = load_conf(conf_dir)
70
+
71
+ if api_name not in conf:
72
+ registered = [k for k in conf if k != "default"]
73
+ if registered:
74
+ click.echo(f"Error: API '{api_name}' is not registered.", err=True)
75
+ click.echo(f"Registered APIs: {', '.join(registered)}", err=True)
76
+ else:
77
+ click.echo("Error: No APIs registered. Run 'papycli init <spec>' first.", err=True)
78
+ sys.exit(1)
79
+
80
+ set_default_api(conf, api_name)
81
+ save_conf(conf, conf_dir)
82
+ click.echo(f"Switched default API to '{api_name}'")
83
+
84
+
85
+ @cli.command("conf")
86
+ def cmd_conf() -> None:
87
+ """現在の設定と環境変数を表示する。"""
88
+ conf_dir = get_conf_dir()
89
+ conf_path = get_conf_path(conf_dir)
90
+
91
+ click.echo(f"Conf dir : {conf_dir}")
92
+ click.echo(f"Conf file : {conf_path}")
93
+ click.echo("")
94
+
95
+ conf = load_conf(conf_dir)
96
+ if not conf:
97
+ click.echo("(no configuration — run 'papycli init <spec>' to get started)")
98
+ return
99
+
100
+ click.echo(json.dumps(conf, indent=2, ensure_ascii=False))
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # summary コマンド
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ @cli.command("summary")
109
+ @click.argument("resource", required=False, default=None)
110
+ @click.option("--csv", "as_csv", is_flag=True, help="CSV フォーマットで出力する")
111
+ def cmd_summary(resource: str | None, as_csv: bool) -> None:
112
+ """登録済み API のエンドポイント一覧を表示する。
113
+
114
+ RESOURCE を指定するとそのパスプレフィックスで絞り込む。
115
+ """
116
+ conf_dir = get_conf_dir()
117
+ try:
118
+ apidef, _ = load_current_apidef(conf_dir)
119
+ except Exception as e:
120
+ click.echo(f"Error: {e}", err=True)
121
+ sys.exit(1)
122
+
123
+ if as_csv:
124
+ click.echo(format_summary_csv(apidef), nl=False)
125
+ else:
126
+ print_summary(apidef, resource_filter=resource)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # API 呼び出しコマンド (get / post / put / patch / delete)
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def _print_response(resp: requests.Response) -> None:
135
+ click.echo(f"HTTP {resp.status_code} {resp.reason}")
136
+ content_type = resp.headers.get("Content-Type", "")
137
+ if "application/json" in content_type:
138
+ try:
139
+ click.echo(json.dumps(resp.json(), indent=2, ensure_ascii=False))
140
+ return
141
+ except ValueError:
142
+ pass
143
+ if resp.text:
144
+ click.echo(resp.text)
145
+
146
+
147
+ def _api_command(method: str) -> click.Command:
148
+ """HTTP メソッドごとの CLI コマンドを生成する。"""
149
+
150
+ @click.command(method, help=f"HTTP {method.upper()} リクエストを送信する。")
151
+ @click.argument("resource")
152
+ @click.option("-q", "query_params", multiple=True, nargs=2, metavar="NAME VALUE",
153
+ help="クエリパラメータ(繰り返し可)")
154
+ @click.option("-p", "body_params", multiple=True, nargs=2, metavar="NAME VALUE",
155
+ help="ボディパラメータ(繰り返し可)")
156
+ @click.option("-d", "raw_body", default=None, metavar="JSON",
157
+ help="生の JSON ボディ(-p を上書き)")
158
+ @click.option("-H", "extra_headers", multiple=True, metavar="HEADER: VALUE",
159
+ help="カスタム HTTP ヘッダー(繰り返し可)")
160
+ @click.option("--summary", "show_summary", is_flag=True,
161
+ help="リクエストを送らずにエンドポイント情報を表示する")
162
+ def _cmd(
163
+ resource: str,
164
+ query_params: tuple[tuple[str, str], ...],
165
+ body_params: tuple[tuple[str, str], ...],
166
+ raw_body: str | None,
167
+ extra_headers: tuple[str, ...],
168
+ show_summary: bool,
169
+ ) -> None:
170
+ conf_dir = get_conf_dir()
171
+ try:
172
+ apidef, base_url = load_current_apidef(conf_dir)
173
+ except Exception as e:
174
+ click.echo(f"Error: {e}", err=True)
175
+ sys.exit(1)
176
+
177
+ if show_summary:
178
+ match = match_path_template(resource, list(apidef.keys()))
179
+ if match is None:
180
+ click.echo(f"Error: No matching path for '{resource}'", err=True)
181
+ sys.exit(1)
182
+ template, _ = match
183
+ click.echo(format_endpoint_detail(apidef, method, template))
184
+ return
185
+
186
+ try:
187
+ resp = call_api(
188
+ method, resource, base_url, apidef,
189
+ query_params=query_params,
190
+ body_params=body_params,
191
+ raw_body=raw_body,
192
+ extra_headers=extra_headers,
193
+ )
194
+ except Exception as e:
195
+ click.echo(f"Error: {e}", err=True)
196
+ sys.exit(1)
197
+
198
+ _print_response(resp)
199
+
200
+ return _cmd
201
+
202
+
203
+ for _method in ("get", "post", "put", "patch", "delete"):
204
+ cli.add_command(_api_command(_method))
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # シェル補完コマンド
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ @cli.command("completion-script")
213
+ @click.argument("shell", type=click.Choice(["bash", "zsh"]))
214
+ def cmd_completion_script(shell: str) -> None:
215
+ """シェル補完スクリプトを出力する。
216
+
217
+ 使い方 (bash): eval "$(papycli completion-script bash)"
218
+
219
+ 使い方 (zsh): eval "$(papycli completion-script zsh)"
220
+ """
221
+ click.echo(generate_script(shell), nl=False)
222
+
223
+
224
+ @cli.command("_complete", hidden=True, context_settings={"ignore_unknown_options": True})
225
+ @click.argument("current_index", type=int)
226
+ @click.argument("words", nargs=-1, type=click.UNPROCESSED)
227
+ def cmd_complete(current_index: int, words: tuple[str, ...]) -> None:
228
+ """シェル補完スクリプトから呼ばれる内部コマンド。補完候補を 1 行 1 候補で出力する。"""
229
+ results = get_completions(list(words), current_index, get_conf_dir())
230
+ if results:
231
+ click.echo("\n".join(results))
papycli/spec_loader.py ADDED
@@ -0,0 +1,142 @@
1
+ """OpenAPI spec の読み込み・$ref 解決・内部フォーマット変換."""
2
+
3
+ import copy
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+
11
+ def load_spec(path: Path) -> dict[str, Any]:
12
+ """JSON または YAML の OpenAPI spec ファイルを読み込む。"""
13
+ with path.open(encoding="utf-8") as f:
14
+ if path.suffix in (".yaml", ".yml"):
15
+ return yaml.safe_load(f) # type: ignore[no-any-return]
16
+ return json.load(f) # type: ignore[no-any-return]
17
+
18
+
19
+ def _resolve_json_pointer(ref: str, root: dict[str, Any]) -> Any:
20
+ """'#/a/b/c' 形式の JSON Pointer を解決する。"""
21
+ if not ref.startswith("#/"):
22
+ raise ValueError(f"Unsupported $ref: {ref!r} (only internal '#/...' refs are supported)")
23
+ parts = ref[2:].split("/")
24
+ node: Any = root
25
+ for part in parts:
26
+ part = part.replace("~1", "/").replace("~0", "~")
27
+ if not isinstance(node, dict) or part not in node:
28
+ raise KeyError(f"$ref target not found: {ref!r}")
29
+ node = node[part]
30
+ return node
31
+
32
+
33
+ def resolve_refs(
34
+ obj: Any,
35
+ root: dict[str, Any],
36
+ _visited: frozenset[str] = frozenset(),
37
+ ) -> Any:
38
+ """オブジェクト中のすべての $ref を再帰的に解決する。循環参照は空 dict で打ち切る。"""
39
+ if isinstance(obj, dict):
40
+ if "$ref" in obj:
41
+ ref: str = obj["$ref"]
42
+ if ref in _visited:
43
+ return {} # 循環参照ガード
44
+ target = _resolve_json_pointer(ref, root)
45
+ return resolve_refs(copy.deepcopy(target), root, _visited | {ref})
46
+ return {k: resolve_refs(v, root, _visited) for k, v in obj.items()}
47
+ if isinstance(obj, list):
48
+ return [resolve_refs(item, root, _visited) for item in obj]
49
+ return obj
50
+
51
+
52
+ def _extract_schema_properties(
53
+ schema: dict[str, Any],
54
+ ) -> tuple[list[str], dict[str, Any]]:
55
+ """スキーマから (required フィールド名リスト, properties dict) を取り出す。allOf に対応。"""
56
+ required: list[str] = []
57
+ properties: dict[str, Any] = {}
58
+
59
+ for sub in schema.get("allOf", []):
60
+ r, p = _extract_schema_properties(sub)
61
+ required.extend(r)
62
+ properties.update(p)
63
+
64
+ required.extend(schema.get("required", []))
65
+ properties.update(schema.get("properties", {}))
66
+ return required, properties
67
+
68
+
69
+ def _param_entry(name: str, schema: dict[str, Any], required: bool) -> dict[str, Any]:
70
+ entry: dict[str, Any] = {
71
+ "name": name,
72
+ "type": schema.get("type", "string"),
73
+ "required": required,
74
+ }
75
+ if "enum" in schema:
76
+ entry["enum"] = schema["enum"]
77
+ return entry
78
+
79
+
80
+ def spec_to_apidef(spec: dict[str, Any]) -> dict[str, Any]:
81
+ """解決済み OpenAPI spec を papycli 内部 API 定義フォーマットに変換する。"""
82
+ apidef: dict[str, Any] = {}
83
+ paths: dict[str, Any] = spec.get("paths", {})
84
+
85
+ for path, path_item in paths.items():
86
+ # path レベルの共通パラメータ
87
+ common_params: dict[str, Any] = {
88
+ p["name"]: p for p in path_item.get("parameters", [])
89
+ }
90
+ methods = []
91
+
92
+ for method in ("get", "post", "put", "patch", "delete"):
93
+ operation: dict[str, Any] | None = path_item.get(method)
94
+ if operation is None:
95
+ continue
96
+
97
+ # path レベル + operation レベルをマージ (operation が優先)
98
+ merged_params = {**common_params}
99
+ for p in operation.get("parameters", []):
100
+ merged_params[p["name"]] = p
101
+
102
+ query_parameters = [
103
+ _param_entry(p["name"], p.get("schema", {}), bool(p.get("required", False)))
104
+ for p in merged_params.values()
105
+ if p.get("in") == "query"
106
+ ]
107
+
108
+ # リクエストボディ (application/json のみ)
109
+ post_parameters: list[dict[str, Any]] = []
110
+ json_schema = (
111
+ operation.get("requestBody", {})
112
+ .get("content", {})
113
+ .get("application/json", {})
114
+ .get("schema", {})
115
+ )
116
+ if json_schema:
117
+ req_fields, props = _extract_schema_properties(json_schema)
118
+ post_parameters = [
119
+ _param_entry(name, prop_schema, name in req_fields)
120
+ for name, prop_schema in props.items()
121
+ ]
122
+
123
+ methods.append(
124
+ {
125
+ "method": method,
126
+ "query_parameters": query_parameters,
127
+ "post_parameters": post_parameters,
128
+ }
129
+ )
130
+
131
+ if methods:
132
+ apidef[path] = methods
133
+
134
+ return apidef
135
+
136
+
137
+ def extract_base_url(spec: dict[str, Any]) -> str:
138
+ """spec から servers[0].url を返す。存在しない場合は空文字。"""
139
+ servers = spec.get("servers", [])
140
+ if servers:
141
+ return str(servers[0].get("url", ""))
142
+ return ""
papycli/summary.py ADDED
@@ -0,0 +1,110 @@
1
+ """--summary / --summary-csv の出力処理。"""
2
+
3
+ import csv
4
+ import io
5
+ from typing import Any, TextIO
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+
11
+ def _format_param(p: dict[str, Any], flag: str) -> str:
12
+ """パラメータを表示用文字列に変換する。
13
+
14
+ 例: -q status*[available|pending|sold] / -p photoUrls*[]
15
+ """
16
+ name = p["name"]
17
+ annotation = ""
18
+ if p.get("required"):
19
+ annotation += "*"
20
+ if p.get("type") == "array":
21
+ annotation += "[]"
22
+ result = f"{flag} {name}{annotation}"
23
+ if "enum" in p:
24
+ enum_str = "|".join(str(e) for e in p["enum"])
25
+ result += f"[{enum_str}]"
26
+ return result
27
+
28
+
29
+ def build_rows(
30
+ apidef: dict[str, Any],
31
+ resource_filter: str | None = None,
32
+ ) -> list[tuple[str, str, str]]:
33
+ """(METHOD, path, params_str) のリストを返す。テストや CSV 生成にも利用。"""
34
+ rows: list[tuple[str, str, str]] = []
35
+ for path in sorted(apidef.keys()):
36
+ if resource_filter and not path.startswith(resource_filter):
37
+ continue
38
+ for op in apidef[path]:
39
+ q_parts = [_format_param(p, "-q") for p in op.get("query_parameters", [])]
40
+ p_parts = [_format_param(p, "-p") for p in op.get("post_parameters", [])]
41
+ params_str = " ".join(q_parts + p_parts)
42
+ rows.append((op["method"].upper(), path, params_str))
43
+ return rows
44
+
45
+
46
+ def print_summary(
47
+ apidef: dict[str, Any],
48
+ resource_filter: str | None = None,
49
+ *,
50
+ file: TextIO | None = None,
51
+ ) -> None:
52
+ """エンドポイント一覧を rich テーブルで出力する。"""
53
+ rows = build_rows(apidef, resource_filter)
54
+ if not rows:
55
+ click_echo = print if file is None else lambda s: file.write(s + "\n")
56
+ click_echo("(no endpoints found)")
57
+ return
58
+
59
+ table = Table(
60
+ show_header=True,
61
+ header_style="bold",
62
+ box=None,
63
+ pad_edge=False,
64
+ show_edge=False,
65
+ )
66
+ table.add_column("METHOD", min_width=8)
67
+ table.add_column("PATH", min_width=24)
68
+ table.add_column("PARAMETERS")
69
+ for method, path, params in rows:
70
+ table.add_row(method, path, params)
71
+
72
+ Console(file=file, highlight=False, no_color=(file is not None)).print(table)
73
+
74
+
75
+ def format_summary_csv(apidef: dict[str, Any]) -> str:
76
+ """エンドポイント一覧を CSV 文字列で返す。"""
77
+ buf = io.StringIO()
78
+ writer = csv.writer(buf)
79
+ writer.writerow(["method", "path", "query_parameters", "post_parameters"])
80
+ for path in sorted(apidef.keys()):
81
+ for op in apidef[path]:
82
+ q_names = ";".join(p["name"] for p in op.get("query_parameters", []))
83
+ p_names = ";".join(p["name"] for p in op.get("post_parameters", []))
84
+ writer.writerow([op["method"].upper(), path, q_names, p_names])
85
+ return buf.getvalue()
86
+
87
+
88
+ def format_endpoint_detail(
89
+ apidef: dict[str, Any],
90
+ method: str,
91
+ template: str,
92
+ ) -> str:
93
+ """特定メソッド+パスのエンドポイント詳細を文字列で返す。"""
94
+ ops = apidef.get(template, [])
95
+ op = next((o for o in ops if o["method"] == method), None)
96
+ if op is None:
97
+ return f"{method.upper()} {template}: not defined"
98
+
99
+ lines = [f"{method.upper()} {template}"]
100
+ if op.get("query_parameters"):
101
+ lines.append(" Query parameters:")
102
+ for p in op["query_parameters"]:
103
+ lines.append(f" {_format_param(p, '-q')}")
104
+ if op.get("post_parameters"):
105
+ lines.append(" Body parameters:")
106
+ for p in op["post_parameters"]:
107
+ lines.append(f" {_format_param(p, '-p')}")
108
+ if not op.get("query_parameters") and not op.get("post_parameters"):
109
+ lines.append(" (no parameters)")
110
+ return "\n".join(lines)
@@ -0,0 +1,252 @@
1
+ Metadata-Version: 2.4
2
+ Name: papycli
3
+ Version: 0.4.0
4
+ Summary: A CLI tool to call REST APIs defined in OpenAPI 3.0 specs
5
+ Project-URL: Homepage, https://github.com/tmonj1/papycli
6
+ Project-URL: Repository, https://github.com/tmonj1/papycli
7
+ Project-URL: Bug Tracker, https://github.com/tmonj1/papycli/issues
8
+ Author-email: tmonj1 <tmonj1@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,cli,http,openapi,rest
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Utilities
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: click>=8.1
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: requests>=2.32
25
+ Requires-Dist: rich>=13.0
26
+ Description-Content-Type: text/markdown
27
+
28
+ # papycli — Python CLI for OpenAPI 3.0 REST APIs
29
+
30
+ `papycli` is an interactive CLI that reads OpenAPI 3.0 specs and lets you call REST API endpoints directly from the terminal.
31
+
32
+ ## Features
33
+
34
+ - Auto-generates a CLI from any OpenAPI 3.0 spec
35
+ - Shell completion for bash and zsh
36
+ - Register and switch between multiple APIs
37
+
38
+ ## Requirements
39
+
40
+ | Item | Notes |
41
+ |------|-------|
42
+ | Python | 3.12 or later |
43
+
44
+ No external tools (e.g. `jq`) required. Works with Python and pip alone.
45
+
46
+ ---
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ pip install papycli
52
+ ```
53
+
54
+ ### Enable Shell Completion
55
+
56
+ **bash:**
57
+
58
+ ```bash
59
+ # Add to ~/.bashrc or ~/.bash_profile
60
+ eval "$(papycli completion-script bash)"
61
+ ```
62
+
63
+ **zsh:**
64
+
65
+ ```bash
66
+ # Add to ~/.zshrc
67
+ eval "$(papycli completion-script zsh)"
68
+ ```
69
+
70
+ Restart your shell or run `source ~/.bashrc` / `source ~/.zshrc` to apply.
71
+
72
+ ---
73
+
74
+ ## Quick Start — Petstore Demo
75
+
76
+ This repository includes a demo using the [Swagger Petstore](https://github.com/swagger-api/swagger-petstore).
77
+
78
+ ### 1. Start the Petstore server
79
+
80
+ ```bash
81
+ docker compose -f examples/docker-compose.yml up -d
82
+ ```
83
+
84
+ The API will be available at `http://localhost:8080/api/v3/`.
85
+
86
+ ### 2. Register the API
87
+
88
+ ```bash
89
+ papycli init examples/petstore-oas3.json
90
+ ```
91
+
92
+ ### 3. Try some commands
93
+
94
+ ```bash
95
+ # List available endpoints
96
+ papycli summary
97
+
98
+ # GET /store/inventory
99
+ papycli get /store/inventory
100
+
101
+ # GET with a path parameter
102
+ papycli get /pet/99
103
+
104
+ # GET with a query parameter
105
+ papycli get /pet/findByStatus -q status available
106
+
107
+ # POST with body parameters
108
+ papycli post /pet -p name "My Dog" -p status available -p photoUrls "http://example.com/photo.jpg"
109
+
110
+ # POST with a raw JSON body
111
+ papycli post /pet -d '{"name": "My Dog", "status": "available", "photoUrls": ["http://example.com/photo.jpg"]}'
112
+
113
+ # Array parameter (repeat the same key)
114
+ papycli put /pet -p id 1 -p name "My Dog" -p photoUrls "http://example.com/a.jpg" -p photoUrls "http://example.com/b.jpg" -p status available
115
+
116
+ # Nested object (dot notation)
117
+ papycli put /pet -p id 1 -p name "My Dog" -p category.id 2 -p category.name "Dogs" -p photoUrls "http://example.com/photo.jpg" -p status available
118
+
119
+ # DELETE /pet/{petId}
120
+ papycli delete /pet/1
121
+ ```
122
+
123
+ ### 4. Tab completion
124
+
125
+ Once shell completion is enabled, tab completion is available:
126
+
127
+ ```
128
+ $ papycli <TAB>
129
+ get post put delete patch
130
+
131
+ $ papycli get <TAB>
132
+ /pet/findByStatus /pet/{petId} /store/inventory ...
133
+
134
+ $ papycli get /pet/findByStatus <TAB>
135
+ -q --summary --help
136
+
137
+ $ papycli get /pet/findByStatus -q <TAB>
138
+ status
139
+
140
+ $ papycli get /pet/findByStatus -q status <TAB>
141
+ available pending sold
142
+ ```
143
+
144
+ ---
145
+
146
+ ## Adding Your Own API
147
+
148
+ ### Step 1 — Run `init`
149
+
150
+ ```bash
151
+ papycli init your-api-spec.json
152
+ ```
153
+
154
+ This command will:
155
+
156
+ 1. Resolve all `$ref` references in the OpenAPI spec
157
+ 2. Convert the spec to papycli's internal API definition format
158
+ 3. Save the result to `$PAPYCLI_CONF_DIR/apis/<name>.json`
159
+ 4. Create or update `$PAPYCLI_CONF_DIR/papycli.conf`
160
+
161
+ The API name is derived from the filename (e.g. `your-api-spec.json` → `your-api-spec`).
162
+
163
+ ### Step 2 — Set the base URL
164
+
165
+ If the spec contains `servers[0].url`, it is used automatically. Otherwise, edit `$PAPYCLI_CONF_DIR/papycli.conf` and set the `url` field:
166
+
167
+ ```json
168
+ {
169
+ "default": "your-api-spec",
170
+ "your-api-spec": {
171
+ "openapispec": "your-api-spec.json",
172
+ "apidef": "your-api-spec.json",
173
+ "url": "https://your-api-base-url/"
174
+ }
175
+ }
176
+ ```
177
+
178
+ ### Managing Multiple APIs
179
+
180
+ ```bash
181
+ # Register multiple APIs
182
+ papycli init petstore-oas3.json
183
+ papycli init myapi.json
184
+
185
+ # Switch the active API
186
+ papycli use myapi
187
+
188
+ # Show registered APIs and the current default
189
+ papycli conf
190
+ ```
191
+
192
+ ---
193
+
194
+ ## Reference
195
+
196
+ ```
197
+ # API management commands
198
+ papycli init <spec-file> Initialize an API from an OpenAPI spec file
199
+ papycli use <api-name> Switch the active API
200
+ papycli conf Show current configuration
201
+ papycli summary [resource] List available endpoints (filter by resource prefix)
202
+ Required params marked with *, array params with []
203
+ papycli summary --csv Output endpoints in CSV format
204
+ papycli completion-script <bash|zsh> Print a shell completion script
205
+
206
+ # API call commands
207
+ papycli <method> <resource> [options]
208
+
209
+ Methods:
210
+ get | post | put | patch | delete
211
+
212
+ Options:
213
+ -H <header: value> Custom HTTP header (repeatable)
214
+ -q <name> <value> Query parameter (repeatable)
215
+ -p <name> <value> Body parameter (repeatable)
216
+ - Repeat the same key to build a JSON array:
217
+ -p tags foo -p tags bar → {"tags":["foo","bar"]}
218
+ - Use dot notation to build a nested object:
219
+ -p category.id 1 -p category.name Dogs
220
+ → {"category":{"id":"1","name":"Dogs"}}
221
+ -d <json> Raw JSON body (overrides -p)
222
+ --summary Show endpoint info without sending a request
223
+ --version Show version
224
+ --help / -h Show help
225
+
226
+ Environment variables:
227
+ PAPYCLI_CONF_DIR Path to the config directory (default: ~/.papycli)
228
+ PAPYCLI_CUSTOM_HEADER Custom HTTP headers applied to every request.
229
+ Separate multiple headers with newlines:
230
+ export PAPYCLI_CUSTOM_HEADER=$'Authorization: Bearer token\nX-Tenant: acme'
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Limitations
236
+
237
+ - Request bodies are `application/json` only
238
+ - Array parameters support scalar element types only (arrays of objects are not supported)
239
+ - Dot notation for nested objects supports one level of nesting only
240
+ - Pass auth headers via `-H "Authorization: Bearer token"` or the `PAPYCLI_CUSTOM_HEADER` env var
241
+
242
+ ---
243
+
244
+ ## Development
245
+
246
+ ```bash
247
+ git clone https://github.com/tmonj1/papycli.git
248
+ cd papycli
249
+ pip install -e ".[dev]"
250
+ ```
251
+
252
+ See [CLAUDE.md](CLAUDE.md) for details.
@@ -0,0 +1,13 @@
1
+ papycli/__init__.py,sha256=eTFWGmWURp7CDwKBSpxW4XRDgrQtKoHSjETJQ3LB_nw,144
2
+ papycli/api_call.py,sha256=N_1h4NJmVpetQDgzCMXuNZfRNHoLebH2I3_44ERdasA,6261
3
+ papycli/completion.py,sha256=ziwxd7-QOlygbAbiD-aWUURpfISCFw-v_yFPAyQXAA8,5508
4
+ papycli/config.py,sha256=XC7YFXhUgnM0xTZ31UGaARflTeFa_J8Oef97grOje68,3153
5
+ papycli/init_cmd.py,sha256=zAhnxXTE1vnjGAv1jhkmCabngEpmh7wciNXh027nq3w,1377
6
+ papycli/main.py,sha256=rneHHmagwLB1j0AWovwKe5_TxFBtNfrY6hiKOUO16bQ,8179
7
+ papycli/spec_loader.py,sha256=tt57BNpQQgfMe1NJzudo6qQmhWPoXf04keHQ0dJ_K_E,4979
8
+ papycli/summary.py,sha256=ukKTl1HCEZEPJh1slOn6PBOg4ChiOGKR_mryvQzR54g,3768
9
+ papycli-0.4.0.dist-info/METADATA,sha256=UFO42F3_JIbDpAnzusK-q5_81FTIsk4NRsfkT6ZHRxs,6826
10
+ papycli-0.4.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ papycli-0.4.0.dist-info/entry_points.txt,sha256=4zszSs05Ut8mKUexwRtCahzOlMzirVqJ9BpCkByyJV8,45
12
+ papycli-0.4.0.dist-info/licenses/LICENSE,sha256=hXmISiCnU9vO9lfSKZSITZpnIgyXF2FBGaINpvmjLmc,1067
13
+ papycli-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ papycli = papycli.main:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Taro Monji
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.