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.
Files changed (122) hide show
  1. pyopenapi_gen/__init__.py +114 -0
  2. pyopenapi_gen/__main__.py +6 -0
  3. pyopenapi_gen/cli.py +86 -0
  4. pyopenapi_gen/context/file_manager.py +52 -0
  5. pyopenapi_gen/context/import_collector.py +382 -0
  6. pyopenapi_gen/context/render_context.py +630 -0
  7. pyopenapi_gen/core/__init__.py +0 -0
  8. pyopenapi_gen/core/auth/base.py +22 -0
  9. pyopenapi_gen/core/auth/plugins.py +89 -0
  10. pyopenapi_gen/core/exceptions.py +25 -0
  11. pyopenapi_gen/core/http_transport.py +219 -0
  12. pyopenapi_gen/core/loader/__init__.py +12 -0
  13. pyopenapi_gen/core/loader/loader.py +158 -0
  14. pyopenapi_gen/core/loader/operations/__init__.py +12 -0
  15. pyopenapi_gen/core/loader/operations/parser.py +155 -0
  16. pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
  17. pyopenapi_gen/core/loader/operations/request_body.py +85 -0
  18. pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
  19. pyopenapi_gen/core/loader/parameters/parser.py +121 -0
  20. pyopenapi_gen/core/loader/responses/__init__.py +10 -0
  21. pyopenapi_gen/core/loader/responses/parser.py +104 -0
  22. pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
  23. pyopenapi_gen/core/loader/schemas/extractor.py +184 -0
  24. pyopenapi_gen/core/pagination.py +64 -0
  25. pyopenapi_gen/core/parsing/__init__.py +13 -0
  26. pyopenapi_gen/core/parsing/common/__init__.py +1 -0
  27. pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
  28. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
  29. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
  30. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
  31. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
  32. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
  33. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
  34. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
  35. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
  36. pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
  37. pyopenapi_gen/core/parsing/common/type_parser.py +74 -0
  38. pyopenapi_gen/core/parsing/context.py +184 -0
  39. pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
  40. pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
  41. pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
  42. pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
  43. pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
  44. pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
  45. pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
  46. pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
  47. pyopenapi_gen/core/parsing/schema_parser.py +610 -0
  48. pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
  49. pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
  50. pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +117 -0
  51. pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
  52. pyopenapi_gen/core/postprocess_manager.py +161 -0
  53. pyopenapi_gen/core/schemas.py +40 -0
  54. pyopenapi_gen/core/streaming_helpers.py +86 -0
  55. pyopenapi_gen/core/telemetry.py +67 -0
  56. pyopenapi_gen/core/utils.py +409 -0
  57. pyopenapi_gen/core/warning_collector.py +83 -0
  58. pyopenapi_gen/core/writers/code_writer.py +135 -0
  59. pyopenapi_gen/core/writers/documentation_writer.py +222 -0
  60. pyopenapi_gen/core/writers/line_writer.py +217 -0
  61. pyopenapi_gen/core/writers/python_construct_renderer.py +274 -0
  62. pyopenapi_gen/core_package_template/README.md +21 -0
  63. pyopenapi_gen/emit/models_emitter.py +143 -0
  64. pyopenapi_gen/emitters/client_emitter.py +51 -0
  65. pyopenapi_gen/emitters/core_emitter.py +181 -0
  66. pyopenapi_gen/emitters/docs_emitter.py +44 -0
  67. pyopenapi_gen/emitters/endpoints_emitter.py +223 -0
  68. pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
  69. pyopenapi_gen/emitters/models_emitter.py +428 -0
  70. pyopenapi_gen/generator/client_generator.py +562 -0
  71. pyopenapi_gen/helpers/__init__.py +1 -0
  72. pyopenapi_gen/helpers/endpoint_utils.py +552 -0
  73. pyopenapi_gen/helpers/type_cleaner.py +341 -0
  74. pyopenapi_gen/helpers/type_helper.py +112 -0
  75. pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
  76. pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
  77. pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
  78. pyopenapi_gen/helpers/type_resolution/finalizer.py +89 -0
  79. pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
  80. pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
  81. pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
  82. pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
  83. pyopenapi_gen/helpers/url_utils.py +14 -0
  84. pyopenapi_gen/http_types.py +20 -0
  85. pyopenapi_gen/ir.py +167 -0
  86. pyopenapi_gen/py.typed +1 -0
  87. pyopenapi_gen/types/__init__.py +11 -0
  88. pyopenapi_gen/types/contracts/__init__.py +13 -0
  89. pyopenapi_gen/types/contracts/protocols.py +106 -0
  90. pyopenapi_gen/types/contracts/types.py +30 -0
  91. pyopenapi_gen/types/resolvers/__init__.py +7 -0
  92. pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
  93. pyopenapi_gen/types/resolvers/response_resolver.py +203 -0
  94. pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
  95. pyopenapi_gen/types/services/__init__.py +5 -0
  96. pyopenapi_gen/types/services/type_service.py +133 -0
  97. pyopenapi_gen/visit/client_visitor.py +228 -0
  98. pyopenapi_gen/visit/docs_visitor.py +38 -0
  99. pyopenapi_gen/visit/endpoint/__init__.py +1 -0
  100. pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
  101. pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
  102. pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
  103. pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
  104. pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
  105. pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
  106. pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
  107. pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
  108. pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
  109. pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
  110. pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
  111. pyopenapi_gen/visit/exception_visitor.py +52 -0
  112. pyopenapi_gen/visit/model/__init__.py +0 -0
  113. pyopenapi_gen/visit/model/alias_generator.py +89 -0
  114. pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
  115. pyopenapi_gen/visit/model/enum_generator.py +200 -0
  116. pyopenapi_gen/visit/model/model_visitor.py +197 -0
  117. pyopenapi_gen/visit/visitor.py +97 -0
  118. pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
  119. pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
  120. pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
  121. pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
  122. 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()
@@ -0,0 +1,6 @@
1
+ """Main entry point for the pyopenapi_gen CLI."""
2
+
3
+ from .cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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)