@@ -135,21 +135,71 @@ def __init__(self, base_path: Path, package_data: dict[str, list[str]]) -> None:
135
135
self .base_path = base_path
136
136
self .package_data = package_data
137
137
138
+ def package_path (self , package_name : str ) -> Path :
139
+ """Return the path of a given package name.
140
+
141
+ The package name can use dotted notation to address sub-packages.
142
+ The top-level package name can optionally include the "-stubs" suffix.
143
+ """
144
+ top_level , * sub_packages = package_name .split ("." )
145
+ if top_level .endswith (SUFFIX ):
146
+ top_level = top_level [: - len (SUFFIX )]
147
+ return self .base_path .joinpath (top_level , * sub_packages )
148
+
149
+ def is_single_file_package (self , package_name : str ) -> bool :
150
+ filename = package_name .split ("-" )[0 ] + ".pyi"
151
+ return (self .base_path / filename ).exists ()
152
+
138
153
@property
139
154
def top_level_packages (self ) -> list [str ]:
140
155
"""Top level package names.
141
156
142
- These are the packages that are not subpackages of any other package
143
- and includes namespace packages.
157
+ These are the packages that are not sub-packages of any other package
158
+ and includes namespace packages. Their name includes the "-stubs"
159
+ suffix.
144
160
"""
145
161
return list (self .package_data .keys ())
146
162
163
+ @property
164
+ def top_level_non_namespace_packages (self ) -> list [str ]:
165
+ """Top level non-namespace package names.
166
+
167
+ This will return all packages that are not subpackages of any other
168
+ package, other than namespace packages in dotted notation, e.g. if
169
+ "flying" is a top level namespace package, and "circus" is a
170
+ non-namespace sub-package, this will return ["flying.circus"].
171
+ """
172
+ packages : list [str ] = []
173
+ for top_level in self .top_level_packages :
174
+ if self .is_single_file_package (top_level ):
175
+ packages .append (top_level )
176
+ else :
177
+ packages .extend (self ._find_non_namespace_sub_packages (top_level ))
178
+ return packages
179
+
180
+ def _find_non_namespace_sub_packages (self , package : str ) -> list [str ]:
181
+ path = self .package_path (package )
182
+ if is_namespace_package (path ):
183
+ sub_packages : list [str ] = []
184
+ for entry in path .iterdir ():
185
+ if entry .is_dir ():
186
+ sub_name = package + "." + entry .name
187
+ sub_packages .extend (self ._find_non_namespace_sub_packages (sub_name ))
188
+ return sub_packages
189
+ else :
190
+ return [package ]
191
+
147
192
def add_file (self , package : str , filename : str , file_contents : str ) -> None :
148
193
"""Add a file to a package."""
149
- entry_path = self .base_path / package
194
+ top_level = package .split ("." )[0 ]
195
+ entry_path = self .package_path (package )
150
196
entry_path .mkdir (exist_ok = True )
151
197
(entry_path / filename ).write_text (file_contents )
152
- self .package_data [package ].append (filename )
198
+ self .package_data [top_level ].append (filename )
199
+
200
+
201
+ def is_namespace_package (path : Path ) -> bool :
202
+ return not (path / "__init__.pyi" ).exists ()
153
203
154
204
155
205
def find_stub_files (top : str ) -> list [str ]:
@@ -166,6 +216,8 @@ def find_stub_files(top: str) -> list[str]:
166
216
name .isidentifier ()
167
217
), "All file names must be valid Python modules"
168
218
result .append (os .path .relpath (os .path .join (root , file ), top ))
219
+ elif file == "py.typed" :
220
+ result .append (os .path .relpath (os .path .join (root , file ), top ))
169
221
elif not file .endswith ((".md" , ".rst" )):
170
222
# Allow having README docs, as some stubs have these (e.g. click).
171
223
if (
@@ -257,7 +309,7 @@ def collect_package_data(base_path: Path) -> PackageData:
257
309
258
310
259
311
def add_partial_markers (pkg_data : PackageData ) -> None :
260
- for package in pkg_data .top_level_packages :
312
+ for package in pkg_data .top_level_non_namespace_packages :
261
313
pkg_data .add_file (package , "py.typed" , "partial\n " )
262
314
263
315
0 commit comments