Skip to content

Commit f6d23b9

Browse files
authored
Check for forbidden files in dependencies with hassfest (home-assistant#150772)
1 parent ab4aeb6 commit f6d23b9

File tree

2 files changed

+97
-25
lines changed

2 files changed

+97
-25
lines changed

script/hassfest/requirements.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import re
1212
import subprocess
1313
import sys
14-
from typing import Any
14+
from typing import Any, TypedDict
1515

1616
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
1717
from tqdm import tqdm
@@ -295,6 +295,9 @@
295295
},
296296
}
297297

298+
FORBIDDEN_FILE_NAMES: set[str] = {
299+
"py.typed", # should be placed inside a package
300+
}
298301
FORBIDDEN_PACKAGE_NAMES: set[str] = {
299302
"doc",
300303
"docs",
@@ -364,7 +367,15 @@
364367
},
365368
}
366369

367-
_packages_checked_files_cache: dict[str, set[str]] = {}
370+
371+
class _PackageFilesCheckResult(TypedDict):
372+
"""Data structure to store results of package files check."""
373+
374+
top_level: set[str]
375+
file_names: set[str]
376+
377+
378+
_packages_checked_files_cache: dict[str, _PackageFilesCheckResult] = {}
368379

369380

370381
def validate(integrations: dict[str, Integration], config: Config) -> None:
@@ -733,24 +744,33 @@ def check_dependency_files(
733744
pkg: str,
734745
package_exceptions: Collection[str],
735746
) -> bool:
736-
"""Check dependency files for forbidden package names."""
747+
"""Check dependency files for forbidden files and forbidden package names."""
737748
if (results := _packages_checked_files_cache.get(pkg)) is None:
738749
top_level: set[str] = set()
750+
file_names: set[str] = set()
739751
for file in files(pkg) or ():
740-
top = file.parts[0].lower()
741-
if top.endswith((".dist-info", ".py")):
742-
continue
743-
top_level.add(top)
744-
results = FORBIDDEN_PACKAGE_NAMES & top_level
752+
if not (top := file.parts[0].lower()).endswith((".dist-info", ".py")):
753+
top_level.add(top)
754+
if (name := str(file)).lower() in FORBIDDEN_FILE_NAMES:
755+
file_names.add(name)
756+
results = _PackageFilesCheckResult(
757+
top_level=FORBIDDEN_PACKAGE_NAMES & top_level,
758+
file_names=file_names,
759+
)
745760
_packages_checked_files_cache[pkg] = results
746-
if not results:
761+
if not (results["top_level"] or results["file_names"]):
747762
return True
748763

749-
for dir_name in results:
764+
for dir_name in results["top_level"]:
750765
integration.add_warning_or_error(
751766
pkg in package_exceptions,
752767
"requirements",
753-
f"Package {pkg} has a forbidden top level directory {dir_name} in {package}",
768+
f"Package {pkg} has a forbidden top level directory '{dir_name}' in {package}",
769+
)
770+
for file_name in results["file_names"]:
771+
integration.add_error(
772+
"requirements",
773+
f"Package {pkg} has a forbidden file '{file_name}' in {package}",
754774
)
755775
return False
756776

tests/hassfest/test_requirements.py

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,8 @@ def test_dependency_version_range_prepare_update(
170170

171171

172172
@pytest.mark.usefixtures("mock_forbidden_package_names")
173-
def test_check_dependency_files(integration: Integration) -> None:
174-
"""Test dependency files check for forbidden package names is working correctly."""
173+
def test_check_dependency_package_names(integration: Integration) -> None:
174+
"""Test dependency package names check for forbidden package names is working correctly."""
175175
package = "homeassistant"
176176
pkg = "my_package"
177177

@@ -190,17 +190,15 @@ def test_check_dependency_files(integration: Integration) -> None:
190190
):
191191
assert not _packages_checked_files_cache
192192
assert check_dependency_files(integration, package, pkg, ()) is False
193-
assert _packages_checked_files_cache[pkg] == {"tests", "test"}
193+
assert _packages_checked_files_cache[pkg]["top_level"] == {"tests", "test"}
194194
assert len(integration.errors) == 2
195195
assert (
196-
f"Package {pkg} has a forbidden top level directory tests in {package}"
197-
in x.error
198-
for x in integration.errors
196+
f"Package {pkg} has a forbidden top level directory 'tests' in {package}"
197+
in [x.error for x in integration.errors]
199198
)
200199
assert (
201-
f"Package {pkg} has a forbidden top level directory test in {package}"
202-
in x.error
203-
for x in integration.errors
200+
f"Package {pkg} has a forbidden top level directory 'test' in {package}"
201+
in [x.error for x in integration.errors]
204202
)
205203
integration.errors.clear()
206204

@@ -227,13 +225,12 @@ def test_check_dependency_files(integration: Integration) -> None:
227225
check_dependency_files(integration, package, pkg, package_exceptions={pkg})
228226
is False
229227
)
230-
assert _packages_checked_files_cache[pkg] == {"tests"}
228+
assert _packages_checked_files_cache[pkg]["top_level"] == {"tests"}
231229
assert len(integration.errors) == 0
232230
assert len(integration.warnings) == 1
233231
assert (
234-
f"Package {pkg} has a forbidden top level directory tests in {package}"
235-
in x.error
236-
for x in integration.warnings
232+
f"Package {pkg} has a forbidden top level directory 'tests' in {package}"
233+
in [x.error for x in integration.warnings]
237234
)
238235
integration.warnings.clear()
239236

@@ -260,7 +257,62 @@ def test_check_dependency_files(integration: Integration) -> None:
260257
):
261258
assert not _packages_checked_files_cache
262259
assert check_dependency_files(integration, package, pkg, ()) is True
263-
assert _packages_checked_files_cache[pkg] == set()
260+
assert _packages_checked_files_cache[pkg]["top_level"] == set()
261+
assert len(integration.errors) == 0
262+
263+
# Repeated call should use cache
264+
assert check_dependency_files(integration, package, pkg, ()) is True
265+
assert mock_files.call_count == 1
266+
assert len(integration.errors) == 0
267+
268+
269+
def test_check_dependency_file_names(integration: Integration) -> None:
270+
"""Test dependency file name check for forbidden files is working correctly."""
271+
package = "homeassistant"
272+
pkg = "my_package"
273+
274+
# Forbidden file: 'py.typed' at top level
275+
pkg_files = [
276+
PackagePath("py.typed"),
277+
PackagePath("my_package.py"),
278+
PackagePath("my_package-1.0.0.dist-info/METADATA"),
279+
]
280+
with (
281+
patch(
282+
"script.hassfest.requirements.files", return_value=pkg_files
283+
) as mock_files,
284+
patch.dict(_packages_checked_files_cache, {}, clear=True),
285+
):
286+
assert not _packages_checked_files_cache
287+
assert check_dependency_files(integration, package, pkg, ()) is False
288+
assert _packages_checked_files_cache[pkg]["file_names"] == {"py.typed"}
289+
assert len(integration.errors) == 1
290+
assert f"Package {pkg} has a forbidden file 'py.typed' in {package}" in [
291+
x.error for x in integration.errors
292+
]
293+
integration.errors.clear()
294+
295+
# Repeated call should use cache
296+
assert check_dependency_files(integration, package, pkg, ()) is False
297+
assert mock_files.call_count == 1
298+
assert len(integration.errors) == 1
299+
integration.errors.clear()
300+
301+
# All good
302+
pkg_files = [
303+
PackagePath("my_package/__init__.py"),
304+
PackagePath("my_package/py.typed"),
305+
PackagePath("my_package.dist-info/METADATA"),
306+
]
307+
with (
308+
patch(
309+
"script.hassfest.requirements.files", return_value=pkg_files
310+
) as mock_files,
311+
patch.dict(_packages_checked_files_cache, {}, clear=True),
312+
):
313+
assert not _packages_checked_files_cache
314+
assert check_dependency_files(integration, package, pkg, ()) is True
315+
assert _packages_checked_files_cache[pkg]["file_names"] == set()
264316
assert len(integration.errors) == 0
265317

266318
# Repeated call should use cache

0 commit comments

Comments
 (0)