Skip to content

Commit a1473fa

Browse files
committed
Add tag filtering with include_tags and exclude_tags
1 parent 7939364 commit a1473fa

11 files changed

Lines changed: 651 additions & 9 deletions

File tree

.changeset/tag_based_filtering.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
default: minor
3+
---
4+
5+
# Add `include_tags` / `exclude_tags` to filter generated endpoints by tag
6+
7+
Endpoints can now be limited to (or excluded by) OpenAPI tags via the `include_tags` / `exclude_tags` config keys or the `--include-tags` / `--exclude-tags` CLI options. Schemas that become unused after filtering are pruned automatically.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ _Be forewarned, this is a beta-level feature in the sense that the API exposed i
6868
for calling the functions in the `api` module.
6969
2. An `api` module which will contain one module for each tag in your OpenAPI spec, as well as a `default` module
7070
for endpoints without a tag. Each of these modules in turn contains one function for calling each endpoint.
71+
You can limit which tags are generated with [`include_tags` / `exclude_tags`](#include_tags-and-exclude_tags).
7172
3. A `models` module which has all the classes defined by the various schemas in your OpenAPI spec
7273
4. A `setup.py` file _if_ you use `--meta=setup` (default is `--meta=poetry`)
7374

@@ -128,6 +129,21 @@ listed, you can enable this option:
128129
generate_all_tags: true
129130
```
130131

132+
### include_tags and exclude_tags
133+
134+
Generate only part of a large API, by tag. Unused schemas are pruned automatically.
135+
136+
```yaml
137+
include_tags: [billing, users] # keep only these
138+
# or
139+
exclude_tags: [admin] # drop these
140+
```
141+
142+
On the CLI, pass them comma-separated: `--include-tags billing,users`. A CLI flag overrides the matching config key.
143+
144+
- `include_tags` and `exclude_tags` are mutually exclusive — use one, not both.
145+
- Tags match the spec exactly (case-sensitive). Untagged endpoints count as `default`.
146+
131147
### project_name_override and package_name_override
132148

133149
Used to change the name of generated client library project/package. If the project name is changed but an override for the package name
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import re
2+
3+
from end_to_end_tests.functional_tests.helpers import (
4+
inline_spec_should_fail,
5+
with_generated_client_fixture,
6+
)
7+
8+
MULTI_TAG_SPEC = """
9+
paths:
10+
"/billing":
11+
post:
12+
operationId: createInvoice
13+
tags: ["billing"]
14+
requestBody:
15+
content:
16+
application/json:
17+
schema: {"$ref": "#/components/schemas/BillingModel"}
18+
responses:
19+
"200":
20+
description: OK
21+
content:
22+
application/json:
23+
schema: {"$ref": "#/components/schemas/SharedModel"}
24+
"/users/me":
25+
get:
26+
operationId: getCurrentUser
27+
tags: ["users"]
28+
responses:
29+
"200":
30+
description: OK
31+
content:
32+
application/json:
33+
schema: {"$ref": "#/components/schemas/SharedModel"}
34+
"/admin/settings":
35+
get:
36+
operationId: getAdminSettings
37+
tags: ["admin"]
38+
responses:
39+
"200":
40+
description: OK
41+
content:
42+
application/json:
43+
schema: {"$ref": "#/components/schemas/AdminModel"}
44+
"/health":
45+
get:
46+
operationId: getHealth
47+
responses:
48+
"200":
49+
description: OK
50+
components:
51+
schemas:
52+
SharedModel:
53+
type: object
54+
properties:
55+
id: {type: string}
56+
status: {"$ref": "#/components/schemas/OrderStatus"}
57+
address: {"$ref": "#/components/schemas/Address"}
58+
Address:
59+
type: object
60+
properties:
61+
city: {type: string}
62+
BillingModel:
63+
type: object
64+
properties:
65+
amount: {type: number}
66+
AdminModel:
67+
type: object
68+
properties:
69+
secret: {type: string}
70+
OrderStatus:
71+
type: string
72+
enum: ["active", "inactive"]
73+
"""
74+
75+
76+
def _generated_package(generated_client):
77+
return generated_client.output_path / generated_client.base_module
78+
79+
80+
def _api_tag_dirs(generated_client) -> set[str]:
81+
api_dir = _generated_package(generated_client) / "api"
82+
return {child.name for child in api_dir.iterdir() if child.is_dir() and child.name != "__pycache__"}
83+
84+
85+
def _model_modules(generated_client) -> set[str]:
86+
models_dir = _generated_package(generated_client) / "models"
87+
return {path.stem for path in models_dir.glob("*.py") if path.stem != "__init__"}
88+
89+
90+
def _dangling_model_imports(generated_client) -> list[str]:
91+
package = _generated_package(generated_client)
92+
existing = {path.stem for path in (package / "models").glob("*.py")}
93+
dangling: list[str] = []
94+
for path in package.rglob("*.py"):
95+
for match in re.finditer(r"from \.+models\.(\w+) import", path.read_text()):
96+
if match.group(1) not in existing:
97+
dangling.append(f"{path.relative_to(package)} -> models.{match.group(1)}")
98+
return sorted(dangling)
99+
100+
101+
@with_generated_client_fixture(MULTI_TAG_SPEC, extra_args=["--include-tags", "billing"])
102+
class TestIncludeTagsViaCli:
103+
def test_only_included_tag_api_module_is_generated(self, generated_client):
104+
assert _api_tag_dirs(generated_client) == {"billing"}
105+
106+
def test_unused_models_are_pruned(self, generated_client):
107+
assert _model_modules(generated_client) == {"billing_model", "shared_model", "order_status", "address"}
108+
109+
def test_pruned_client_has_no_dangling_imports(self, generated_client):
110+
generated_client.import_module(".models")
111+
assert _dangling_model_imports(generated_client) == []
112+
113+
114+
@with_generated_client_fixture(MULTI_TAG_SPEC, config="include_tags: [billing]")
115+
class TestIncludeTagsViaConfigFile:
116+
def test_only_included_tag_api_module_is_generated(self, generated_client):
117+
assert _api_tag_dirs(generated_client) == {"billing"}
118+
119+
def test_unused_models_are_pruned(self, generated_client):
120+
assert _model_modules(generated_client) == {"billing_model", "shared_model", "order_status", "address"}
121+
122+
123+
@with_generated_client_fixture(MULTI_TAG_SPEC, extra_args=["--exclude-tags", "admin"])
124+
class TestExcludeTagsViaCli:
125+
def test_excluded_tag_api_module_is_dropped(self, generated_client):
126+
assert _api_tag_dirs(generated_client) == {"billing", "users", "default"}
127+
128+
def test_only_admin_models_are_pruned(self, generated_client):
129+
assert _model_modules(generated_client) == {"billing_model", "shared_model", "order_status", "address"}
130+
131+
132+
@with_generated_client_fixture(MULTI_TAG_SPEC, config="exclude_tags: [admin]")
133+
class TestExcludeTagsViaConfigFile:
134+
def test_excluded_tag_api_module_is_dropped(self, generated_client):
135+
assert _api_tag_dirs(generated_client) == {"billing", "users", "default"}
136+
137+
def test_only_admin_models_are_pruned(self, generated_client):
138+
assert _model_modules(generated_client) == {"billing_model", "shared_model", "order_status", "address"}
139+
140+
141+
class TestMutuallyExclusiveTagFlags:
142+
def test_both_flags_exits_nonzero(self):
143+
result = inline_spec_should_fail(
144+
MULTI_TAG_SPEC,
145+
extra_args=["--include-tags", "billing", "--exclude-tags", "admin"],
146+
)
147+
assert "Provide either include_tags or exclude_tags, not both" in result.output

openapi_python_client/cli.py

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ def _version_callback(value: bool) -> None:
1818
raise typer.Exit()
1919

2020

21+
def _split_comma_separated(value: str | None) -> list[str]:
22+
if not value:
23+
return []
24+
return [item.strip() for item in value.split(",") if item.strip()]
25+
26+
27+
def _load_config_file(
28+
*,
29+
config_path: Path | None,
30+
) -> ConfigFile:
31+
if not config_path:
32+
config_file = ConfigFile()
33+
else:
34+
try:
35+
config_file = ConfigFile.load_from_path(path=config_path)
36+
except Exception as err:
37+
raise typer.BadParameter("Unable to parse config") from err
38+
39+
return config_file
40+
41+
2142
def _process_config(
2243
*,
2344
url: str | None,
@@ -27,6 +48,8 @@ def _process_config(
2748
file_encoding: str,
2849
overwrite: bool,
2950
output_path: Path | None,
51+
include_tags: str | None = None,
52+
exclude_tags: str | None = None,
3053
) -> Config:
3154
source: Path | str
3255
if url and not path:
@@ -46,13 +69,16 @@ def _process_config(
4669
typer.secho(f"Unknown encoding : {file_encoding}", fg=typer.colors.RED)
4770
raise typer.Exit(code=1) from err
4871

49-
if not config_path:
50-
config_file = ConfigFile()
51-
else:
52-
try:
53-
config_file = ConfigFile.load_from_path(path=config_path)
54-
except Exception as err:
55-
raise typer.BadParameter("Unable to parse config") from err
72+
config_file = _load_config_file(config_path=config_path)
73+
74+
if include_tags is not None:
75+
config_file.include_tags = _split_comma_separated(include_tags)
76+
if exclude_tags is not None:
77+
config_file.exclude_tags = _split_comma_separated(exclude_tags)
78+
79+
if config_file.include_tags and config_file.exclude_tags:
80+
typer.secho("Provide either include_tags or exclude_tags, not both", fg=typer.colors.RED)
81+
raise typer.Exit(code=1)
5682

5783
return Config.from_sources(config_file, meta_type, source, file_encoding, overwrite, output_path=output_path)
5884

@@ -148,6 +174,20 @@ def generate(
148174
"Defaults to the OpenAPI document title converted to kebab or snake case (depending on meta type). "
149175
"Can also be overridden with `project_name_override` or `package_name_override` in config.",
150176
),
177+
include_tags: str | None = typer.Option(
178+
None,
179+
"--include-tags",
180+
help="Comma-separated tags to generate. "
181+
"Keeps matching endpoints, drops the rest, prunes unused schemas. "
182+
"Case-sensitive. Overrides config. Can't combine with --exclude-tags.",
183+
),
184+
exclude_tags: str | None = typer.Option(
185+
None,
186+
"--exclude-tags",
187+
help="Comma-separated tags to skip. "
188+
"Drops matching endpoints, keeps the rest, prunes unused schemas. "
189+
"Case-sensitive. Overrides config. Can't combine with --include-tags.",
190+
),
151191
) -> None:
152192
"""Generate a new OpenAPI Client library"""
153193
from . import generate # noqa: PLC0415
@@ -160,6 +200,8 @@ def generate(
160200
file_encoding=file_encoding,
161201
overwrite=overwrite,
162202
output_path=output_path,
203+
include_tags=include_tags,
204+
exclude_tags=exclude_tags,
163205
)
164206
errors = generate(
165207
custom_template_path=custom_template_path,

openapi_python_client/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ class ConfigFile(BaseModel):
4646
generate_all_tags: bool = False
4747
http_timeout: int = 5
4848
literal_enums: bool = False
49+
include_tags: list[str] | None = None
50+
exclude_tags: list[str] | None = None
4951

5052
@staticmethod
5153
def load_from_path(path: Path) -> "ConfigFile":
@@ -76,6 +78,8 @@ class Config:
7678
generate_all_tags: bool
7779
http_timeout: int
7880
literal_enums: bool
81+
include_tags: list[str]
82+
exclude_tags: list[str]
7983
document_source: Path | str
8084
file_encoding: str
8185
content_type_overrides: dict[str, str]
@@ -118,6 +122,8 @@ def from_sources(
118122
generate_all_tags=config_file.generate_all_tags,
119123
http_timeout=config_file.http_timeout,
120124
literal_enums=config_file.literal_enums,
125+
include_tags=(config_file.include_tags or []),
126+
exclude_tags=(config_file.exclude_tags or []),
121127
document_source=document_source,
122128
file_encoding=file_encoding,
123129
overwrite=overwrite,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from __future__ import annotations
2+
3+
__all__ = ["get_reachable_classes"]
4+
5+
from collections.abc import Iterable, Mapping
6+
from typing import TYPE_CHECKING
7+
8+
from ..utils import ClassName
9+
from .properties import (
10+
EnumProperty,
11+
ListProperty,
12+
LiteralEnumProperty,
13+
ModelProperty,
14+
Property,
15+
UnionProperty,
16+
)
17+
from .properties.protocol import PropertyProtocol
18+
19+
if TYPE_CHECKING: # pragma: no cover
20+
from .openapi import Endpoint
21+
22+
23+
def get_reachable_classes(
24+
*,
25+
endpoints: Iterable[Endpoint],
26+
classes_by_name: Mapping[ClassName, Property],
27+
) -> set[ClassName]:
28+
"""Class names reachable from the given endpoints. Anything else is safe to prune.
29+
30+
Walks each endpoint's properties transitively, collecting models and enums by name.
31+
Re-fetches each model from ``classes_by_name`` on descent, since the copy an endpoint
32+
holds may have empty properties.
33+
"""
34+
reachable: set[ClassName] = set()
35+
stack: list[PropertyProtocol] = []
36+
for endpoint in endpoints:
37+
stack.extend(endpoint.list_all_parameters())
38+
stack.extend(response.prop for response in endpoint.responses)
39+
40+
while stack:
41+
prop = stack.pop()
42+
if isinstance(prop, ModelProperty):
43+
name = prop.class_info.name
44+
if name in reachable:
45+
continue
46+
reachable.add(name)
47+
canonical = classes_by_name.get(name, prop)
48+
if isinstance(canonical, ModelProperty):
49+
stack.extend(canonical.required_properties or [])
50+
stack.extend(canonical.optional_properties or [])
51+
if canonical.additional_properties is not None:
52+
stack.append(canonical.additional_properties)
53+
elif isinstance(prop, EnumProperty | LiteralEnumProperty):
54+
reachable.add(prop.class_info.name)
55+
elif isinstance(prop, ListProperty):
56+
stack.append(prop.inner_property)
57+
elif isinstance(prop, UnionProperty):
58+
stack.extend(prop.inner_properties)
59+
60+
return reachable

0 commit comments

Comments
 (0)