Skip to content

Commit 7b639ec

Browse files
authored
feat: use repo as source of truth (#317)
1 parent 640e122 commit 7b639ec

File tree

7 files changed

+113
-138
lines changed

7 files changed

+113
-138
lines changed

README.md

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
[![Python Version](https://img.shields.io/badge/python-3.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue?logo=python&logoColor=yellow)](https://pypi.org/project/twyn/)
77
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
88
[![License](https://img.shields.io/github/license/elementsinteractive/twyn)](LICENSE)
9+
910
## Table of Contents
1011

1112
- [Overview](#overview)
@@ -171,18 +172,14 @@ allowlist=["my_package"]
171172
source="https://mirror-with-trusted-dependencies.com/file.json"
172173
```
173174

174-
> [!WARNING]
175-
> `twyn` will have a default reference URL for every source of trusted packages that is configurable.
176-
> If you want to protect yourself against spoofing attacks, it is recommended to set your own
177-
> reference url.
178-
179175
The file format for each reference is as follows:
180176

181-
- **PyPI reference**:
182-
183-
```ts
177+
```jsonc
184178
{
185-
rows: {project: string}[]
179+
"date": "string (ISO 8601 format, e.g. 2025-09-10T14:23:00+00)",
180+
"packages": [
181+
{ "name": "string" }
182+
]
186183
}
187184
```
188185

src/twyn/trusted_packages/references/base.py

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from twyn.trusted_packages.cache_handler import CacheEntry, CacheHandler
99
from twyn.trusted_packages.exceptions import (
10+
EmptyPackagesListError,
1011
InvalidJSONError,
1112
)
1213

@@ -28,26 +29,19 @@ def __init__(self, source: Optional[str] = None, cache_handler: Union[CacheHandl
2829
self.source = source or self.DEFAULT_SOURCE
2930
self.cache_handler = cache_handler
3031

31-
@staticmethod
32-
@abstractmethod
33-
def _parse(packages_json: dict[str, Any]) -> set[str]:
34-
"""Parse and retrieve the packages within the given json structure."""
35-
3632
@staticmethod
3733
@abstractmethod
3834
def normalize_packages(packages: set[str]) -> set[str]:
3935
"""Normalize package names to make sure they're valid within the package manager context."""
4036

4137
def _download(self) -> dict[str, Any]:
42-
packages = requests.get(self.source)
43-
packages.raise_for_status()
38+
response = requests.get(self.source)
39+
response.raise_for_status()
40+
4441
try:
45-
packages_json: dict[str, Any] = packages.json()
42+
return response.json()
4643
except requests.exceptions.JSONDecodeError as err:
4744
raise InvalidJSONError from err
48-
else:
49-
logger.debug("Successfully downloaded trusted packages list from %s", self.source)
50-
return packages_json
5145

5246
def _save_trusted_packages_to_cache_if_enabled(self, packages: set[str]) -> None:
5347
"""Save trusted packages using CacheHandler."""
@@ -69,18 +63,24 @@ def _get_packages_from_cache_if_enabled(self) -> set[str]:
6963
return cache_entry.packages
7064

7165
def get_packages(self) -> set[str]:
72-
"""Download and parse online source of top Python Package Index packages."""
73-
packages_to_use = set()
74-
packages_to_use = self._get_packages_from_cache_if_enabled()
66+
"""Download and parse online source of top packages from the package ecosystem."""
67+
packages = self._get_packages_from_cache_if_enabled()
7568
# we don't save the cache here, we keep it as it is so the date remains the original one.
76-
77-
if not packages_to_use:
69+
if not packages:
7870
# no cache usage, no cache hit (non-existent or outdated) or cache was empty.
7971
logger.info("Fetching trusted packages from trusted packages reference...")
80-
packages_to_use = self._parse(self._download())
72+
data = self._download()
73+
try:
74+
packages = set(data["packages"])
75+
except KeyError as err:
76+
raise InvalidJSONError("`packages` key not in JSON.") from err
77+
78+
logger.debug("Successfully downloaded trusted packages list from %s", self.source)
79+
if not packages:
80+
raise EmptyPackagesListError
8181

8282
# New packages were downloaded, we create a new entry updating all values.
83-
self._save_trusted_packages_to_cache_if_enabled(packages_to_use)
83+
self._save_trusted_packages_to_cache_if_enabled(packages)
8484

85-
normalized_packages = self.normalize_packages(packages_to_use)
85+
normalized_packages = self.normalize_packages(packages)
8686
return normalized_packages

src/twyn/trusted_packages/references/top_npm_reference.py

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import logging
22
import re
3-
from typing import Any
43

54
from typing_extensions import override
65

76
from twyn.trusted_packages.exceptions import (
8-
EmptyPackagesListError,
9-
InvalidReferenceFormatError,
107
PackageNormalizingError,
118
)
129
from twyn.trusted_packages.references.base import AbstractPackageReference
@@ -17,22 +14,9 @@
1714
class TopNpmReference(AbstractPackageReference):
1815
"""Top npm packages retrieved from an online source."""
1916

20-
DEFAULT_SOURCE: str = "https://www.npmleaderboard.org/api/packages"
21-
22-
@override
23-
@staticmethod
24-
def _parse(packages_info: dict[str, Any]) -> set[str]:
25-
try:
26-
names = {pkg["name"] for pkg in packages_info["packages"]}
27-
28-
except KeyError as err:
29-
raise InvalidReferenceFormatError from err
30-
31-
if not names:
32-
raise EmptyPackagesListError
33-
34-
logger.debug("Successfully parsed trusted packages list")
35-
return names
17+
DEFAULT_SOURCE: str = (
18+
"https://raw.githubusercontent.com/elementsinteractive/twyn/refs/heads/main/dependencies/npm.json"
19+
)
3620

3721
@override
3822
@staticmethod

src/twyn/trusted_packages/references/top_pypi_reference.py

Lines changed: 3 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import logging
22
import re
3-
from typing import Any
43

54
from typing_extensions import override
65

76
from twyn.trusted_packages.exceptions import (
8-
EmptyPackagesListError,
9-
InvalidReferenceFormatError,
107
PackageNormalizingError,
118
)
129
from twyn.trusted_packages.references.base import AbstractPackageReference
@@ -17,21 +14,9 @@
1714
class TopPyPiReference(AbstractPackageReference):
1815
"""Top PyPi packages retrieved from an online source."""
1916

20-
DEFAULT_SOURCE: str = "https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json"
21-
22-
@override
23-
@staticmethod
24-
def _parse(packages_info: dict[str, Any]) -> set[str]:
25-
try:
26-
names = {row["project"] for row in packages_info["rows"]}
27-
except KeyError as err:
28-
raise InvalidReferenceFormatError from err
29-
30-
if not names:
31-
raise EmptyPackagesListError
32-
33-
logger.debug("Successfully parsed trusted packages list")
34-
return names
17+
DEFAULT_SOURCE: str = (
18+
"https://raw.githubusercontent.com/elementsinteractive/twyn/refs/heads/main/dependencies/pypi.json"
19+
)
3520

3621
@override
3722
@staticmethod

tests/conftest.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from collections.abc import Iterable, Iterator
1+
import datetime
2+
from collections.abc import Iterator
23
from contextlib import contextmanager
34
from pathlib import Path
45
from unittest import mock
@@ -14,12 +15,12 @@ def create_tmp_file(path: Path, data: str) -> Iterator[Path]:
1415

1516

1617
@contextmanager
17-
def patch_pypi_packages_download(packages: Iterable[str]) -> Iterator[mock.Mock]:
18+
def patch_pypi_packages_download(packages: list[str]) -> Iterator[mock.Mock]:
1819
"""Patcher of `requests.get` for Top PyPi list.
1920
2021
Replaces call with the output you would get from downloading the top PyPi packages list.
2122
"""
22-
json_response = {"rows": [{"project": name} for name in packages]}
23+
json_response = {"packages": packages, "date": datetime.datetime.now().isoformat()}
2324

2425
with mock.patch("twyn.trusted_packages.TopPyPiReference._download") as mock_download:
2526
mock_download.return_value = json_response
@@ -28,12 +29,12 @@ def patch_pypi_packages_download(packages: Iterable[str]) -> Iterator[mock.Mock]
2829

2930

3031
@contextmanager
31-
def patch_npm_packages_download(packages: Iterable[str]) -> Iterator[mock.Mock]:
32+
def patch_npm_packages_download(packages: list[str]) -> Iterator[mock.Mock]:
3233
"""Patcher of `requests.get` for Top Npm list.
3334
3435
Replaces call with the output you would get from downloading the top Npm packages list.
3536
"""
36-
json_response = {"packages": [{"name": name} for name in packages]}
37+
json_response = {"packages": packages, "date": datetime.datetime.now().isoformat()}
3738

3839
with mock.patch("twyn.trusted_packages.TopNpmReference._download") as mock_download:
3940
mock_download.return_value = json_response

tests/main/test_main.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -286,32 +286,6 @@ def test_check_dependencies_ignores_package_in_allowlist(
286286

287287
assert error == TyposquatCheckResultList(errors=[])
288288

289-
@pytest.mark.parametrize(
290-
"package_name",
291-
[
292-
"my.package",
293-
"my-package",
294-
"my_package",
295-
"My.Package",
296-
],
297-
)
298-
@patch("twyn.trusted_packages.TopPyPiReference._get_packages_from_cache_if_enabled")
299-
def test_normalize_package(self, mock_get_packages_from_cache: Mock, package_name: Mock) -> None:
300-
mock_get_packages_from_cache.return_value = {"requests", "mypackage"}
301-
error = check_dependencies(
302-
config_file=None,
303-
dependency_file=None,
304-
dependencies={package_name},
305-
selector_method="first-letter",
306-
package_ecosystem="pypi",
307-
)
308-
309-
assert error == TyposquatCheckResultList(
310-
errors=[
311-
TyposquatCheckResult(dependency="my-package", similars=["mypackage"]),
312-
]
313-
)
314-
315289
@patch("twyn.trusted_packages.TopPyPiReference.get_packages")
316290
def test_check_dependencies_does_not_error_on_same_package(
317291
self, mock_get_packages: Mock, uv_lock_file_with_typo: Path

0 commit comments

Comments
 (0)