pyopenapi-gen 0.8.3__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.
- pyopenapi_gen/__init__.py +114 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +86 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +630 -0
- pyopenapi_gen/core/__init__.py +0 -0
- pyopenapi_gen/core/auth/base.py +22 -0
- pyopenapi_gen/core/auth/plugins.py +89 -0
- pyopenapi_gen/core/exceptions.py +25 -0
- pyopenapi_gen/core/http_transport.py +219 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +158 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +155 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
- pyopenapi_gen/core/loader/operations/request_body.py +85 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +121 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +104 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +184 -0
- pyopenapi_gen/core/pagination.py +64 -0
- pyopenapi_gen/core/parsing/__init__.py +13 -0
- pyopenapi_gen/core/parsing/common/__init__.py +1 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
- pyopenapi_gen/core/parsing/common/type_parser.py +74 -0
- pyopenapi_gen/core/parsing/context.py +184 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
- pyopenapi_gen/core/parsing/schema_parser.py +610 -0
- pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
- pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +117 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +161 -0
- pyopenapi_gen/core/schemas.py +40 -0
- pyopenapi_gen/core/streaming_helpers.py +86 -0
- pyopenapi_gen/core/telemetry.py +67 -0
- pyopenapi_gen/core/utils.py +409 -0
- pyopenapi_gen/core/warning_collector.py +83 -0
- pyopenapi_gen/core/writers/code_writer.py +135 -0
- pyopenapi_gen/core/writers/documentation_writer.py +222 -0
- pyopenapi_gen/core/writers/line_writer.py +217 -0
- pyopenapi_gen/core/writers/python_construct_renderer.py +274 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -0
- pyopenapi_gen/emitters/client_emitter.py +51 -0
- pyopenapi_gen/emitters/core_emitter.py +181 -0
- pyopenapi_gen/emitters/docs_emitter.py +44 -0
- pyopenapi_gen/emitters/endpoints_emitter.py +223 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
- pyopenapi_gen/emitters/models_emitter.py +428 -0
- pyopenapi_gen/generator/client_generator.py +562 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +552 -0
- pyopenapi_gen/helpers/type_cleaner.py +341 -0
- pyopenapi_gen/helpers/type_helper.py +112 -0
- pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
- pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
- pyopenapi_gen/helpers/type_resolution/finalizer.py +89 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +167 -0
- pyopenapi_gen/py.typed +1 -0
- pyopenapi_gen/types/__init__.py +11 -0
- pyopenapi_gen/types/contracts/__init__.py +13 -0
- pyopenapi_gen/types/contracts/protocols.py +106 -0
- pyopenapi_gen/types/contracts/types.py +30 -0
- pyopenapi_gen/types/resolvers/__init__.py +7 -0
- pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
- pyopenapi_gen/types/resolvers/response_resolver.py +203 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +133 -0
- pyopenapi_gen/visit/client_visitor.py +228 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +52 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +89 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
- pyopenapi_gen/visit/model/enum_generator.py +200 -0
- pyopenapi_gen/visit/model/model_visitor.py +197 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
- pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
- pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
- pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
- pyopenapi_gen-0.8.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,114 @@
|
|
1
|
+
"""pyopenapi_gen – Core package
|
2
|
+
|
3
|
+
This package provides the internal representation (IR) dataclasses that act as an
|
4
|
+
intermediate layer between the parsed OpenAPI specification and the code
|
5
|
+
emitters. The IR aims to be a *stable*, *fully‑typed* model that the rest of the
|
6
|
+
code‑generation pipeline can rely on.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from __future__ import annotations
|
10
|
+
|
11
|
+
# Removed dataclasses, field, Enum, unique from here, they are in ir.py and http_types.py
|
12
|
+
from typing import (
|
13
|
+
TYPE_CHECKING,
|
14
|
+
Any,
|
15
|
+
List,
|
16
|
+
)
|
17
|
+
|
18
|
+
# Kept Any, List, Optional, TYPE_CHECKING for __getattr__ and __dir__
|
19
|
+
# Import HTTPMethod from its canonical location
|
20
|
+
from .http_types import HTTPMethod
|
21
|
+
|
22
|
+
# Import IR classes from their canonical location
|
23
|
+
from .ir import (
|
24
|
+
IROperation,
|
25
|
+
IRParameter,
|
26
|
+
IRRequestBody,
|
27
|
+
IRResponse,
|
28
|
+
IRSchema,
|
29
|
+
IRSpec,
|
30
|
+
)
|
31
|
+
|
32
|
+
__all__ = [
|
33
|
+
"HTTPMethod",
|
34
|
+
"IRParameter",
|
35
|
+
"IRResponse",
|
36
|
+
"IROperation",
|
37
|
+
"IRSchema",
|
38
|
+
"IRSpec",
|
39
|
+
"IRRequestBody",
|
40
|
+
# Keep existing __all__ entries for lazy loaded items
|
41
|
+
"load_ir_from_spec",
|
42
|
+
"WarningCollector",
|
43
|
+
]
|
44
|
+
|
45
|
+
# Semantic version of the generator core – bumped manually for now.
|
46
|
+
__version__: str = "0.6.0"
|
47
|
+
|
48
|
+
|
49
|
+
# ---------------------------------------------------------------------------
|
50
|
+
# HTTP Method Enum - REMOVED FROM HERE
|
51
|
+
# ---------------------------------------------------------------------------
|
52
|
+
|
53
|
+
# @unique
|
54
|
+
# class HTTPMethod(str, Enum):
|
55
|
+
# ...
|
56
|
+
|
57
|
+
|
58
|
+
# ---------------------------------------------------------------------------
|
59
|
+
# IR Dataclasses - REMOVED FROM HERE
|
60
|
+
# ---------------------------------------------------------------------------
|
61
|
+
|
62
|
+
# @dataclass(slots=True)
|
63
|
+
# class IRParameter:
|
64
|
+
# ...
|
65
|
+
|
66
|
+
# @dataclass(slots=True)
|
67
|
+
# class IRResponse:
|
68
|
+
# ...
|
69
|
+
|
70
|
+
# @dataclass(slots=True)
|
71
|
+
# class IRRequestBody:
|
72
|
+
# ...
|
73
|
+
|
74
|
+
# @dataclass(slots=True)
|
75
|
+
# class IROperation:
|
76
|
+
# ...
|
77
|
+
|
78
|
+
# @dataclass(slots=True)
|
79
|
+
# class IRSchema:
|
80
|
+
# ...
|
81
|
+
|
82
|
+
# @dataclass(slots=True)
|
83
|
+
# class IRSpec:
|
84
|
+
# ...
|
85
|
+
|
86
|
+
|
87
|
+
# ---------------------------------------------------------------------------
|
88
|
+
# Lazy-loading and autocompletion support (This part remains)
|
89
|
+
# ---------------------------------------------------------------------------
|
90
|
+
if TYPE_CHECKING:
|
91
|
+
# Imports for static analysis
|
92
|
+
from .core.loader.loader import load_ir_from_spec # noqa: F401
|
93
|
+
from .core.warning_collector import WarningCollector # noqa: F401
|
94
|
+
|
95
|
+
# Expose loader and collector at package level
|
96
|
+
# __all__ is already updated above
|
97
|
+
|
98
|
+
|
99
|
+
def __getattr__(name: str) -> Any:
|
100
|
+
# Lazy-import attributes for runtime, supports IDE completion via TYPE_CHECKING
|
101
|
+
if name == "load_ir_from_spec":
|
102
|
+
from .core.loader.loader import load_ir_from_spec as _func
|
103
|
+
|
104
|
+
return _func
|
105
|
+
if name == "WarningCollector":
|
106
|
+
from .core.warning_collector import WarningCollector as _cls
|
107
|
+
|
108
|
+
return _cls
|
109
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
110
|
+
|
111
|
+
|
112
|
+
def __dir__() -> List[str]:
|
113
|
+
# Ensure dir() and completion shows all exports
|
114
|
+
return __all__.copy()
|
pyopenapi_gen/cli.py
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Any, Dict, Optional, Union
|
3
|
+
|
4
|
+
import typer
|
5
|
+
import yaml
|
6
|
+
|
7
|
+
from .generator.client_generator import ClientGenerator, GenerationError
|
8
|
+
|
9
|
+
app = typer.Typer(invoke_without_command=True)
|
10
|
+
|
11
|
+
|
12
|
+
@app.callback()
|
13
|
+
def main(ctx: typer.Context) -> None:
|
14
|
+
"""
|
15
|
+
PyOpenAPI Generator CLI.
|
16
|
+
"""
|
17
|
+
if ctx.invoked_subcommand is None:
|
18
|
+
# Show basic help without using ctx.get_help() to avoid Click compatibility issues
|
19
|
+
typer.echo("PyOpenAPI Generator CLI")
|
20
|
+
typer.echo("")
|
21
|
+
typer.echo("Usage: pyopenapi-gen [OPTIONS] COMMAND [ARGS]...")
|
22
|
+
typer.echo("")
|
23
|
+
typer.echo("Commands:")
|
24
|
+
typer.echo(" gen Generate a Python OpenAPI client from a spec file or URL")
|
25
|
+
typer.echo("")
|
26
|
+
typer.echo("Run 'pyopenapi-gen gen --help' for more information on the gen command.")
|
27
|
+
raise typer.Exit(code=0)
|
28
|
+
|
29
|
+
|
30
|
+
def _load_spec(path_or_url: str) -> Union[Dict[str, Any], Any]:
|
31
|
+
"""Load a spec from a file path or URL."""
|
32
|
+
if Path(path_or_url).exists():
|
33
|
+
return yaml.safe_load(Path(path_or_url).read_text())
|
34
|
+
typer.echo("URL loading not implemented", err=True)
|
35
|
+
raise typer.Exit(code=1)
|
36
|
+
|
37
|
+
|
38
|
+
@app.command()
|
39
|
+
def gen(
|
40
|
+
spec: str = typer.Argument(..., help="Path or URL to OpenAPI spec"),
|
41
|
+
project_root: Path = typer.Option(
|
42
|
+
...,
|
43
|
+
"--project-root",
|
44
|
+
help=(
|
45
|
+
"Path to the directory containing your top-level Python packages. "
|
46
|
+
"Generated code will be placed at project-root + output-package path."
|
47
|
+
),
|
48
|
+
),
|
49
|
+
output_package: str = typer.Option(
|
50
|
+
..., "--output-package", help="Python package path for the generated client (e.g., 'pyapis.my_api_client')."
|
51
|
+
),
|
52
|
+
force: bool = typer.Option(False, "-f", "--force", help="Overwrite without diff check"),
|
53
|
+
no_postprocess: bool = typer.Option(False, "--no-postprocess", help="Skip post-processing (type checking, etc.)"),
|
54
|
+
core_package: Optional[str] = typer.Option(
|
55
|
+
None,
|
56
|
+
"--core-package",
|
57
|
+
help=(
|
58
|
+
"Python package path for the core package (e.g., 'pyapis.core'). "
|
59
|
+
"If not set, defaults to <output-package>.core."
|
60
|
+
),
|
61
|
+
),
|
62
|
+
) -> None:
|
63
|
+
"""
|
64
|
+
Generate a Python OpenAPI client from a spec file or URL.
|
65
|
+
Only parses CLI arguments and delegates to ClientGenerator.
|
66
|
+
"""
|
67
|
+
if core_package is None:
|
68
|
+
core_package = output_package + ".core"
|
69
|
+
generator = ClientGenerator()
|
70
|
+
try:
|
71
|
+
generator.generate(
|
72
|
+
spec_path=str(Path(spec).resolve()),
|
73
|
+
project_root=project_root,
|
74
|
+
output_package=output_package,
|
75
|
+
force=force,
|
76
|
+
no_postprocess=no_postprocess,
|
77
|
+
core_package=core_package,
|
78
|
+
)
|
79
|
+
typer.echo("Client generation complete.")
|
80
|
+
except GenerationError as e:
|
81
|
+
typer.echo(f"Generation failed: {e}", err=True)
|
82
|
+
raise typer.Exit(code=1)
|
83
|
+
|
84
|
+
|
85
|
+
if __name__ == "__main__":
|
86
|
+
app()
|
@@ -0,0 +1,52 @@
|
|
1
|
+
"""
|
2
|
+
FileManager: Manages file operations for code generation.
|
3
|
+
|
4
|
+
This module provides utilities for creating directories and writing
|
5
|
+
generated Python code to files, with appropriate logging for debugging.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import os
|
9
|
+
import tempfile
|
10
|
+
|
11
|
+
|
12
|
+
class FileManager:
|
13
|
+
"""
|
14
|
+
Manages file operations for the code generation process.
|
15
|
+
|
16
|
+
This class provides methods to write content to files and ensure directories
|
17
|
+
exist, with built-in logging for debugging purposes.
|
18
|
+
"""
|
19
|
+
|
20
|
+
def write_file(self, path: str, content: str) -> None:
|
21
|
+
"""
|
22
|
+
Write content to a file, ensuring the parent directory exists.
|
23
|
+
|
24
|
+
This method also logs the file path and first 10 lines of content
|
25
|
+
to a debug log for troubleshooting purposes.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
path: The absolute path to the file to write
|
29
|
+
content: The string content to write to the file
|
30
|
+
"""
|
31
|
+
self.ensure_dir(os.path.dirname(path))
|
32
|
+
|
33
|
+
# Log the file path and first 10 lines of content for debugging
|
34
|
+
debug_log_path = os.path.join(tempfile.gettempdir(), "pyopenapi_gen_file_write_debug.log")
|
35
|
+
with open(debug_log_path, "a") as debug_log:
|
36
|
+
debug_log.write(f"WRITE FILE: {path}\n")
|
37
|
+
for line in content.splitlines()[:10]:
|
38
|
+
debug_log.write(line + "\n")
|
39
|
+
debug_log.write("---\n")
|
40
|
+
|
41
|
+
# Write the content to the file
|
42
|
+
with open(path, "w") as f:
|
43
|
+
f.write(content)
|
44
|
+
|
45
|
+
def ensure_dir(self, path: str) -> None:
|
46
|
+
"""
|
47
|
+
Ensure a directory exists, creating it if necessary.
|
48
|
+
|
49
|
+
Args:
|
50
|
+
path: The directory path to ensure exists
|
51
|
+
"""
|
52
|
+
os.makedirs(path, exist_ok=True)
|
@@ -0,0 +1,382 @@
|
|
1
|
+
"""
|
2
|
+
ImportCollector: Manages imports for generated Python modules.
|
3
|
+
|
4
|
+
This module provides the ImportCollector class, which collects, organizes, and formats
|
5
|
+
import statements for Python modules. It supports various import styles, including standard,
|
6
|
+
direct, relative, and plain imports, with methods to add and query import statements.
|
7
|
+
"""
|
8
|
+
|
9
|
+
import logging
|
10
|
+
import sys
|
11
|
+
from collections import defaultdict
|
12
|
+
from typing import Dict, List, Optional, Set
|
13
|
+
|
14
|
+
# Initialize module logger
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
# Standard library modules for _is_stdlib check
|
18
|
+
COMMON_STDLIB = {
|
19
|
+
"typing",
|
20
|
+
"os",
|
21
|
+
"sys",
|
22
|
+
"re",
|
23
|
+
"json",
|
24
|
+
"collections",
|
25
|
+
"datetime",
|
26
|
+
"enum",
|
27
|
+
"pathlib",
|
28
|
+
"abc",
|
29
|
+
"contextlib",
|
30
|
+
"functools",
|
31
|
+
"itertools",
|
32
|
+
"logging",
|
33
|
+
"math",
|
34
|
+
"decimal",
|
35
|
+
"dataclasses",
|
36
|
+
"asyncio",
|
37
|
+
"tempfile",
|
38
|
+
"subprocess",
|
39
|
+
"textwrap",
|
40
|
+
}
|
41
|
+
|
42
|
+
# Stdlib modules that should prefer \'import module\' over \'from module import module\'
|
43
|
+
# when add_import(module, module) is called.
|
44
|
+
STDLIB_MODULES_PREFER_PLAIN_IMPORT_WHEN_NAME_MATCHES = {
|
45
|
+
"os",
|
46
|
+
"sys",
|
47
|
+
"re",
|
48
|
+
"json",
|
49
|
+
"contextlib",
|
50
|
+
"functools",
|
51
|
+
"itertools",
|
52
|
+
"logging",
|
53
|
+
"math",
|
54
|
+
"asyncio",
|
55
|
+
"tempfile",
|
56
|
+
"subprocess",
|
57
|
+
"textwrap",
|
58
|
+
}
|
59
|
+
|
60
|
+
|
61
|
+
def _is_stdlib(module_name: str) -> bool:
|
62
|
+
"""Check if a module is part of the standard library."""
|
63
|
+
top_level_module = module_name.split(".")[0]
|
64
|
+
return module_name in sys.builtin_module_names or module_name in COMMON_STDLIB or top_level_module in COMMON_STDLIB
|
65
|
+
|
66
|
+
|
67
|
+
def make_relative_import(current_module_dot_path: str, target_module_dot_path: str) -> str:
|
68
|
+
"""Generate a relative import path string from current_module to target_module."""
|
69
|
+
current_parts = current_module_dot_path.split(".")
|
70
|
+
target_parts = target_module_dot_path.split(".")
|
71
|
+
|
72
|
+
current_dir_parts = current_parts[:-1]
|
73
|
+
|
74
|
+
# Calculate common prefix length (L) between current_dir_parts and the full target_parts
|
75
|
+
L = 0
|
76
|
+
while L < len(current_dir_parts) and L < len(target_parts) and current_dir_parts[L] == target_parts[L]:
|
77
|
+
L += 1
|
78
|
+
|
79
|
+
# Number of levels to go "up" from current_module's directory to the common ancestor with target.
|
80
|
+
up_levels = len(current_dir_parts) - L
|
81
|
+
|
82
|
+
# The remaining components of the target path, after this common prefix L.
|
83
|
+
remaining_target_components = target_parts[L:]
|
84
|
+
|
85
|
+
if up_levels == 0:
|
86
|
+
# This means the common prefix L makes current_dir_parts a prefix of (or same as)
|
87
|
+
# target_parts's directory structure portion.
|
88
|
+
# Or, target is in a subdirectory of current_dir_parts[L-1]
|
89
|
+
|
90
|
+
# Special case for importing a submodule from its parent package's __init__.py
|
91
|
+
# e.g. current="pkg.sub" (representing pkg/sub/__init__.py), target="pkg.sub.mod"
|
92
|
+
# Expected: ".mod"
|
93
|
+
is_direct_package_import = len(current_parts) < len(target_parts) and target_module_dot_path.startswith(
|
94
|
+
current_module_dot_path + "."
|
95
|
+
)
|
96
|
+
|
97
|
+
if is_direct_package_import:
|
98
|
+
# current_parts = [pkg, sub], target_parts = [pkg, sub, mod]
|
99
|
+
# We want target_parts after current_parts, i.e., [mod]
|
100
|
+
final_suffix_parts = target_parts[len(current_parts) :]
|
101
|
+
else:
|
102
|
+
# General case for up_levels == 0.
|
103
|
+
# e.g. current="pkg.mod1" (dir pkg), target="pkg.mod2" (dir pkg)
|
104
|
+
# current_dir_parts=[pkg], target_parts=[pkg,mod2]. L=1 (for pkg).
|
105
|
+
# up_levels = 1-1=0. remaining_target_components=target_parts[1:]=[mod2]. -> .mod2
|
106
|
+
# e.g. current="pkg.mod1" (dir pkg), target="pkg.sub.mod2" (dir pkg.sub)
|
107
|
+
# current_dir_parts=[pkg], target_parts=[pkg,sub,mod2]. L=1.
|
108
|
+
# up_levels = 0. remaining_target_components=target_parts[1:]=[sub,mod2]. -> .sub.mod2
|
109
|
+
final_suffix_parts = remaining_target_components
|
110
|
+
|
111
|
+
return "." + ".".join(final_suffix_parts)
|
112
|
+
else: # up_levels >= 1
|
113
|
+
# up_levels = 1 means one step up ("..")
|
114
|
+
# up_levels = N means N steps up (N+1 dots)
|
115
|
+
return ("." * (up_levels + 1)) + ".".join(remaining_target_components)
|
116
|
+
|
117
|
+
|
118
|
+
class ImportCollector:
|
119
|
+
"""
|
120
|
+
Manages imports for generated Python modules.
|
121
|
+
|
122
|
+
This class collects and organizes imports in a structured way, ensuring
|
123
|
+
consistency across all generated files. It provides methods to add different
|
124
|
+
types of imports and generate properly formatted import statements.
|
125
|
+
|
126
|
+
Attributes:
|
127
|
+
imports: Dictionary mapping module names to sets of imported names
|
128
|
+
(for standard imports like `from typing import List`)
|
129
|
+
direct_imports: Dictionary for direct imports (similar to imports)
|
130
|
+
relative_imports: Dictionary for relative imports (like `from .models import Pet`)
|
131
|
+
plain_imports: Set of module names for plain imports (like `import json`)
|
132
|
+
|
133
|
+
Example usage:
|
134
|
+
imports = ImportCollector()
|
135
|
+
imports.add_import("dataclasses", "dataclass")
|
136
|
+
imports.add_typing_import("Optional")
|
137
|
+
imports.add_typing_import("List")
|
138
|
+
|
139
|
+
for statement in imports.get_import_statements():
|
140
|
+
print(statement)
|
141
|
+
"""
|
142
|
+
|
143
|
+
def __init__(self) -> None:
|
144
|
+
"""Initialize a new ImportCollector with empty collections for all import types."""
|
145
|
+
# Standard imports (from x import y)
|
146
|
+
self.imports: Dict[str, Set[str]] = {}
|
147
|
+
# Direct imports like 'from datetime import date'
|
148
|
+
# self.direct_imports: Dict[str, Set[str]] = {} # Removed
|
149
|
+
# Relative imports like 'from .models import Pet'
|
150
|
+
self.relative_imports: defaultdict[str, set[str]] = defaultdict(set)
|
151
|
+
# Plain imports like 'import json'
|
152
|
+
self.plain_imports: set[str] = set()
|
153
|
+
|
154
|
+
# Path information for the current file, used by get_formatted_imports
|
155
|
+
self._current_file_module_dot_path: Optional[str] = None
|
156
|
+
self._current_file_package_root: Optional[str] = None
|
157
|
+
self._current_file_core_pkg_name_for_abs: Optional[str] = None
|
158
|
+
|
159
|
+
def reset(self) -> None:
|
160
|
+
"""Reset the collector to its initial empty state."""
|
161
|
+
self.imports.clear()
|
162
|
+
self.relative_imports.clear()
|
163
|
+
self.plain_imports.clear()
|
164
|
+
self._current_file_module_dot_path = None
|
165
|
+
self._current_file_package_root = None
|
166
|
+
self._current_file_core_pkg_name_for_abs = None
|
167
|
+
|
168
|
+
def set_current_file_context_for_rendering(
|
169
|
+
self,
|
170
|
+
current_module_dot_path: Optional[str],
|
171
|
+
package_root: Optional[str],
|
172
|
+
core_package_name_for_absolute_treatment: Optional[str],
|
173
|
+
) -> None:
|
174
|
+
"""Set the context for the current file, used by get_formatted_imports."""
|
175
|
+
self._current_file_module_dot_path = current_module_dot_path
|
176
|
+
self._current_file_package_root = package_root
|
177
|
+
self._current_file_core_pkg_name_for_abs = core_package_name_for_absolute_treatment
|
178
|
+
|
179
|
+
def add_import(self, module: str, name: str) -> None:
|
180
|
+
"""
|
181
|
+
Add an import from a specific module.
|
182
|
+
|
183
|
+
Args:
|
184
|
+
module: The module to import from (e.g., "typing")
|
185
|
+
name: The name to import (e.g., "List")
|
186
|
+
"""
|
187
|
+
# If module and name are the same, and it's a stdlib module
|
188
|
+
# that typically uses plain import style (e.g., "import os").
|
189
|
+
if module == name and module in STDLIB_MODULES_PREFER_PLAIN_IMPORT_WHEN_NAME_MATCHES:
|
190
|
+
self.add_plain_import(module)
|
191
|
+
else:
|
192
|
+
if module not in self.imports:
|
193
|
+
self.imports[module] = set()
|
194
|
+
self.imports[module].add(name)
|
195
|
+
|
196
|
+
def add_imports(self, module: str, names: List[str]) -> None:
|
197
|
+
"""
|
198
|
+
Add multiple imports from a module.
|
199
|
+
|
200
|
+
Args:
|
201
|
+
module: The module to import from
|
202
|
+
names: List of names to import
|
203
|
+
"""
|
204
|
+
for name in names:
|
205
|
+
self.add_import(module, name)
|
206
|
+
|
207
|
+
def add_typing_import(self, name: str) -> None:
|
208
|
+
"""
|
209
|
+
Shortcut for adding typing imports.
|
210
|
+
|
211
|
+
Args:
|
212
|
+
name: The typing name to import (e.g., "List", "Optional")
|
213
|
+
"""
|
214
|
+
self.add_import("typing", name)
|
215
|
+
|
216
|
+
def add_relative_import(self, module: str, name: str) -> None:
|
217
|
+
"""
|
218
|
+
Add a relative import module and name.
|
219
|
+
|
220
|
+
Args:
|
221
|
+
module: The relative module path (e.g., ".models")
|
222
|
+
name: The name to import
|
223
|
+
"""
|
224
|
+
if module not in self.relative_imports:
|
225
|
+
self.relative_imports[module] = set()
|
226
|
+
self.relative_imports[module].add(name)
|
227
|
+
|
228
|
+
def add_plain_import(self, module: str) -> None:
|
229
|
+
"""
|
230
|
+
Add a plain import (import x).
|
231
|
+
|
232
|
+
Args:
|
233
|
+
module: The module to import
|
234
|
+
"""
|
235
|
+
self.plain_imports.add(module)
|
236
|
+
|
237
|
+
def has_import(self, module: str, name: Optional[str] = None) -> bool:
|
238
|
+
"""Check if a specific module or name within a module is already imported."""
|
239
|
+
if name:
|
240
|
+
# Check absolute/standard imports
|
241
|
+
if module in self.imports and name in self.imports[module]:
|
242
|
+
return True
|
243
|
+
# Check relative imports
|
244
|
+
if module in self.relative_imports and name in self.relative_imports[module]:
|
245
|
+
return True
|
246
|
+
else:
|
247
|
+
# Check plain imports (e.g. "import os" where module="os", name=None)
|
248
|
+
if module in self.plain_imports:
|
249
|
+
return True
|
250
|
+
|
251
|
+
return False
|
252
|
+
|
253
|
+
def get_import_statements(self) -> List[str]:
|
254
|
+
"""
|
255
|
+
Generates a list of import statement strings.
|
256
|
+
Order: plain, standard (from x import y), relative (from .x import y).
|
257
|
+
Uses path context set by `set_current_file_context_for_rendering`.
|
258
|
+
"""
|
259
|
+
# Use internal state for path context
|
260
|
+
current_module_dot_path_to_use = self._current_file_module_dot_path
|
261
|
+
package_root_to_use = self._current_file_package_root
|
262
|
+
core_package_name_to_use = self._current_file_core_pkg_name_for_abs
|
263
|
+
|
264
|
+
standard_import_lines: List[str] = []
|
265
|
+
|
266
|
+
for module_name, names_set in sorted(self.imports.items()):
|
267
|
+
names = sorted(list(names_set))
|
268
|
+
is_stdlib_module = _is_stdlib(module_name)
|
269
|
+
|
270
|
+
is_core_module_to_be_absolute = False
|
271
|
+
if core_package_name_to_use and (
|
272
|
+
module_name.startswith(core_package_name_to_use + ".") or module_name == core_package_name_to_use
|
273
|
+
):
|
274
|
+
is_core_module_to_be_absolute = True
|
275
|
+
|
276
|
+
if is_core_module_to_be_absolute:
|
277
|
+
import_statement = f"from {module_name} import {', '.join(names)}"
|
278
|
+
standard_import_lines.append(import_statement)
|
279
|
+
elif is_stdlib_module:
|
280
|
+
import_statement = f"from {module_name} import {', '.join(names)}"
|
281
|
+
standard_import_lines.append(import_statement)
|
282
|
+
elif (
|
283
|
+
current_module_dot_path_to_use
|
284
|
+
and package_root_to_use
|
285
|
+
and module_name.startswith(package_root_to_use + ".")
|
286
|
+
):
|
287
|
+
try:
|
288
|
+
relative_module = make_relative_import(current_module_dot_path_to_use, module_name)
|
289
|
+
import_statement = f"from {relative_module} import {', '.join(names)}"
|
290
|
+
standard_import_lines.append(import_statement)
|
291
|
+
except ValueError as e:
|
292
|
+
import_statement = f"from {module_name} import {', '.join(names)}"
|
293
|
+
standard_import_lines.append(import_statement)
|
294
|
+
else:
|
295
|
+
import_statement = f"from {module_name} import {', '.join(names)}"
|
296
|
+
standard_import_lines.append(import_statement)
|
297
|
+
|
298
|
+
plain_import_lines: List[str] = []
|
299
|
+
for module in sorted(self.plain_imports):
|
300
|
+
plain_import_lines.append(f"import {module}")
|
301
|
+
|
302
|
+
filtered_relative_imports: defaultdict[str, set[str]] = defaultdict(set)
|
303
|
+
for module, names_to_import in self.relative_imports.items():
|
304
|
+
# A module from self.relative_imports always starts with '.' (e.g., ".models")
|
305
|
+
# Include it unless it's a self-import relative to a known current_module_dot_path.
|
306
|
+
is_self_import = current_module_dot_path_to_use is not None and module == current_module_dot_path_to_use
|
307
|
+
if not is_self_import:
|
308
|
+
filtered_relative_imports[module].update(names_to_import)
|
309
|
+
|
310
|
+
relative_import_lines: List[str] = []
|
311
|
+
for module, imported_names in sorted(filtered_relative_imports.items()):
|
312
|
+
names_str = ", ".join(sorted(list(imported_names)))
|
313
|
+
relative_import_lines.append(f"from {module} import {names_str}")
|
314
|
+
|
315
|
+
import_lines: List[str] = (
|
316
|
+
list(sorted(plain_import_lines)) + list(sorted(standard_import_lines)) + list(sorted(relative_import_lines))
|
317
|
+
)
|
318
|
+
return import_lines
|
319
|
+
|
320
|
+
def get_formatted_imports(self) -> str:
|
321
|
+
"""
|
322
|
+
Get all imports as a formatted string.
|
323
|
+
|
324
|
+
Returns:
|
325
|
+
A newline-separated string of import statements
|
326
|
+
"""
|
327
|
+
statements: List[str] = []
|
328
|
+
|
329
|
+
# Standard library imports first
|
330
|
+
stdlib_modules = sorted([m for m in self.imports.keys() if _is_stdlib(m)])
|
331
|
+
|
332
|
+
for module in stdlib_modules:
|
333
|
+
names = sorted(self.imports[module])
|
334
|
+
statements.append(f"from {module} import {', '.join(names)}")
|
335
|
+
|
336
|
+
# Then third-party and app imports
|
337
|
+
other_modules = sorted([m for m in self.imports.keys() if not _is_stdlib(m)])
|
338
|
+
|
339
|
+
if stdlib_modules and other_modules:
|
340
|
+
statements.append("") # Add a blank line between stdlib and other imports
|
341
|
+
|
342
|
+
for module in other_modules:
|
343
|
+
names = sorted(self.imports[module])
|
344
|
+
statements.append(f"from {module} import {', '.join(names)}")
|
345
|
+
|
346
|
+
# Then plain imports
|
347
|
+
if self.plain_imports:
|
348
|
+
if statements: # Add blank line if we have imports already
|
349
|
+
statements.append("")
|
350
|
+
|
351
|
+
for module in sorted(self.plain_imports):
|
352
|
+
statements.append(f"import {module}")
|
353
|
+
|
354
|
+
# Then relative imports
|
355
|
+
if self.relative_imports and (stdlib_modules or other_modules or self.plain_imports):
|
356
|
+
statements.append("") # Add a blank line before relative imports
|
357
|
+
|
358
|
+
for module in sorted(self.relative_imports.keys()):
|
359
|
+
names = sorted(self.relative_imports[module])
|
360
|
+
statements.append(f"from {module} import {', '.join(names)}")
|
361
|
+
|
362
|
+
return "\n".join(statements)
|
363
|
+
|
364
|
+
def merge(self, other: "ImportCollector") -> None:
|
365
|
+
"""
|
366
|
+
Merge imports from another ImportCollector instance.
|
367
|
+
|
368
|
+
This method combines all imports from the other collector into this one.
|
369
|
+
|
370
|
+
Args:
|
371
|
+
other: Another ImportCollector instance to merge imports from
|
372
|
+
"""
|
373
|
+
for module, names in other.imports.items():
|
374
|
+
if module not in self.imports:
|
375
|
+
self.imports[module] = set()
|
376
|
+
self.imports[module].update(names)
|
377
|
+
for module, names in other.relative_imports.items():
|
378
|
+
if module not in self.relative_imports:
|
379
|
+
self.relative_imports[module] = set()
|
380
|
+
self.relative_imports[module].update(names)
|
381
|
+
for module in other.plain_imports:
|
382
|
+
self.plain_imports.add(module)
|