ossiq 0.1.0__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 (66) hide show
  1. ossiq/__init__.py +4 -0
  2. ossiq/__main__.py +4 -0
  3. ossiq/adapters/__init__.py +0 -0
  4. ossiq/adapters/api.py +47 -0
  5. ossiq/adapters/api_github.py +295 -0
  6. ossiq/adapters/api_interfaces.py +138 -0
  7. ossiq/adapters/api_npm.py +195 -0
  8. ossiq/adapters/api_osv.py +98 -0
  9. ossiq/adapters/api_pypi.py +274 -0
  10. ossiq/adapters/detectors.py +19 -0
  11. ossiq/adapters/package_managers/__init__.py +0 -0
  12. ossiq/adapters/package_managers/api.py +32 -0
  13. ossiq/adapters/package_managers/api_npm.py +217 -0
  14. ossiq/adapters/package_managers/api_pip.py +251 -0
  15. ossiq/adapters/package_managers/api_pip_classic.py +184 -0
  16. ossiq/adapters/package_managers/api_uv.py +172 -0
  17. ossiq/adapters/package_managers/utils.py +25 -0
  18. ossiq/cli.py +143 -0
  19. ossiq/commands/__init__.py +0 -0
  20. ossiq/commands/export.py +68 -0
  21. ossiq/commands/scan.py +71 -0
  22. ossiq/domain/__init__.py +0 -0
  23. ossiq/domain/common.py +99 -0
  24. ossiq/domain/cve.py +40 -0
  25. ossiq/domain/exceptions.py +40 -0
  26. ossiq/domain/package.py +77 -0
  27. ossiq/domain/packages_manager.py +107 -0
  28. ossiq/domain/project.py +90 -0
  29. ossiq/domain/repository.py +28 -0
  30. ossiq/domain/version.py +271 -0
  31. ossiq/messages.py +35 -0
  32. ossiq/service/__init__.py +0 -0
  33. ossiq/service/common/__init__.py +0 -0
  34. ossiq/service/common/package_versions.py +107 -0
  35. ossiq/service/project.py +166 -0
  36. ossiq/settings.py +39 -0
  37. ossiq/timeutil.py +59 -0
  38. ossiq/ui/__init__.py +0 -0
  39. ossiq/ui/html/__init__.py +0 -0
  40. ossiq/ui/html/filter_format_highlight_days.py +42 -0
  41. ossiq/ui/html/tag_versions_difference.py +44 -0
  42. ossiq/ui/html/template_environment.py +46 -0
  43. ossiq/ui/interfaces.py +59 -0
  44. ossiq/ui/registry.py +72 -0
  45. ossiq/ui/renderers/__init__.py +0 -0
  46. ossiq/ui/renderers/export/__init__.py +1 -0
  47. ossiq/ui/renderers/export/csv.py +469 -0
  48. ossiq/ui/renderers/export/csv_datapackage.py +172 -0
  49. ossiq/ui/renderers/export/csv_schema_registry.py +220 -0
  50. ossiq/ui/renderers/export/json.py +73 -0
  51. ossiq/ui/renderers/export/json_schema_registry.py +70 -0
  52. ossiq/ui/renderers/export/models.py +171 -0
  53. ossiq/ui/renderers/scan/__init__.py +1 -0
  54. ossiq/ui/renderers/scan/console.py +131 -0
  55. ossiq/ui/renderers/scan/html.py +65 -0
  56. ossiq/ui/system.py +67 -0
  57. ossiq/unit_of_work/__init__.py +0 -0
  58. ossiq/unit_of_work/core.py +43 -0
  59. ossiq/unit_of_work/uow_project.py +83 -0
  60. ossiq-0.1.0.dist-info/METADATA +849 -0
  61. ossiq-0.1.0.dist-info/RECORD +66 -0
  62. ossiq-0.1.0.dist-info/WHEEL +5 -0
  63. ossiq-0.1.0.dist-info/entry_points.txt +2 -0
  64. ossiq-0.1.0.dist-info/licenses/AUTHORS +8 -0
  65. ossiq-0.1.0.dist-info/licenses/LICENSE +661 -0
  66. ossiq-0.1.0.dist-info/top_level.txt +1 -0
ossiq/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Top-level package for Update Burden."""
2
+
3
+ __author__ = """Maksym Klymyshyn"""
4
+ __email__ = "klymyshyn@gmail.com"
ossiq/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
File without changes
ossiq/adapters/api.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ Factory to instantiate API clients
3
+ """
4
+
5
+ from ossiq.adapters.api_npm import PackageRegistryApiNpm
6
+ from ossiq.adapters.api_osv import CveApiOsv
7
+ from ossiq.adapters.api_pypi import PackageRegistryApiPypi
8
+ from ossiq.domain.common import ProjectPackagesRegistry, RepositoryProvider
9
+ from ossiq.settings import Settings
10
+
11
+ from .api_github import SourceCodeProviderApiGithub
12
+ from .api_interfaces import AbstractCveDatabaseApi, AbstractPackageRegistryApi, AbstractSourceCodeProviderApi
13
+
14
+
15
+ def create_source_code_provider(provider_type: RepositoryProvider, settings: Settings) -> AbstractSourceCodeProviderApi:
16
+ """
17
+ Return source code provider (like Github) using factory and respective type
18
+ """
19
+ if provider_type == RepositoryProvider.PROVIDER_GITHUB:
20
+ return SourceCodeProviderApiGithub(settings.github_token)
21
+ else:
22
+ raise ValueError(f"Unknown source code provider type: {provider_type}")
23
+
24
+
25
+ def create_package_registry_api(
26
+ package_registry: ProjectPackagesRegistry, settings: Settings
27
+ ) -> AbstractPackageRegistryApi:
28
+ """
29
+ Create instance of a specific ecosystem's package registry API
30
+ """
31
+
32
+ if package_registry == ProjectPackagesRegistry.NPM:
33
+ return PackageRegistryApiNpm(settings)
34
+ elif package_registry == ProjectPackagesRegistry.PYPI:
35
+ return PackageRegistryApiPypi(settings)
36
+ else:
37
+ raise ValueError(f"Unknown package registry: {package_registry}")
38
+
39
+
40
+ def create_cve_database() -> AbstractCveDatabaseApi:
41
+ """
42
+ Return CVE database (like osv.dev). The purpose is little different from
43
+ Source Code Provider or Packages Registry/Ecosystem: there might be
44
+ more than one CSV database. For externel clients it should look like a
45
+ single database instance still.
46
+ """
47
+ return CveApiOsv()
@@ -0,0 +1,295 @@
1
+ """
2
+ Implementation of SourceCodeApiClient for Github
3
+ """
4
+
5
+ import datetime
6
+ import itertools
7
+ import re
8
+ from collections.abc import Callable, Iterable
9
+
10
+ import requests
11
+ from rich.console import Console
12
+
13
+ from ..domain.common import VERSION_DATA_SOURCE_GITHUB_RELEASES, VERSION_DATA_SOURCE_GITHUB_TAGS, RepositoryProvider
14
+ from ..domain.exceptions import GithubRateLimitError
15
+ from ..domain.repository import Repository
16
+ from ..domain.version import Commit, PackageVersion, RepositoryVersion, User, sort_versions
17
+ from .api_interfaces import AbstractSourceCodeProviderApi
18
+
19
+ console = Console()
20
+
21
+ GITHUB_API = "https://api.github.com"
22
+
23
+
24
+ class SourceCodeProviderApiGithub(AbstractSourceCodeProviderApi):
25
+ """
26
+ Implementation of SourceCodeApiClient for Github
27
+ """
28
+
29
+ repository_provider: RepositoryProvider = RepositoryProvider.PROVIDER_GITHUB
30
+
31
+ github_token: str | None
32
+
33
+ def __init__(self, github_token: str | None):
34
+ self.github_token = github_token
35
+ if not self.github_token:
36
+ # FIXME: pass warning
37
+ pass
38
+
39
+ def __repr__(self):
40
+ return "<SourceCodeProviderApiGithub instance>"
41
+
42
+ def _extract_next_url(self, link_header: str):
43
+ """
44
+ Parse header <https://api.github.com/repositories/47118129/tags?page=2>;
45
+ rel="next" and extract URL
46
+ """
47
+ if link_header is None:
48
+ return None
49
+
50
+ match = re.search(r"<(.*?)>; rel=\"next\"", link_header)
51
+ if match:
52
+ return match.group(1)
53
+
54
+ return None
55
+
56
+ def _make_github_api_request(self, url: str, timeout: int = 15) -> tuple[str | None, dict]:
57
+ """
58
+ Make a request to the GitHub API and properly handle pagination
59
+ """
60
+ headers = {}
61
+ if self.github_token:
62
+ headers["Authorization"] = f"Bearer {self.github_token}"
63
+
64
+ response = requests.get(url, timeout=timeout, headers=headers)
65
+
66
+ # Basically let user know that we're done here with Github.
67
+ if response.status_code == 403:
68
+ remaining_rate_limit = response.headers.get("x-ratelimit-remaining", "N/A")
69
+
70
+ try:
71
+ reset_rate_limit_time = datetime.datetime.fromtimestamp(
72
+ int(response.headers.get("x-ratelimit-reset", "N/A"))
73
+ ).isoformat()
74
+ except (ValueError, TypeError):
75
+ reset_rate_limit_time = "N/A"
76
+
77
+ total_rate_limit = response.headers.get("x-ratelimit-limit", "N/A")
78
+
79
+ raise GithubRateLimitError(
80
+ remaining=remaining_rate_limit,
81
+ total=total_rate_limit,
82
+ reset_time=reset_rate_limit_time,
83
+ )
84
+
85
+ response.raise_for_status()
86
+
87
+ return self._extract_next_url(response.headers.get("Link", None)), response.json()
88
+
89
+ def _paginate_github_api_request(self, url: str) -> Iterable[dict]:
90
+ """
91
+ Paginate responses from Github API
92
+ """
93
+ next_url = url
94
+
95
+ while next_url:
96
+ next_url, data = self._make_github_api_request(next_url)
97
+ yield from data
98
+
99
+ def _load_releases(self, repository: Repository, versions_set: set[str]) -> Iterable[RepositoryVersion]:
100
+ """
101
+ Fetch releases from a GitHub repo that match the provided versions.
102
+ """
103
+ url = f"{GITHUB_API}/repos/{repository.owner}/{repository.name}/releases"
104
+
105
+ n = 0
106
+ # NOTE: we need to pull all the releases we're interested in and then break iteration
107
+ for release in self._paginate_github_api_request(url):
108
+ normalized_tag = release["tag_name"]
109
+ if normalized_tag in versions_set:
110
+ yield RepositoryVersion(
111
+ version_source_type=VERSION_DATA_SOURCE_GITHUB_RELEASES,
112
+ version=normalized_tag,
113
+ ref_name=release["tag_name"],
114
+ release_name=release["name"],
115
+ release_notes=release.get("body", None),
116
+ source_url=release["html_url"],
117
+ patch_url=None,
118
+ commits=None,
119
+ ref_previous=None,
120
+ )
121
+ n += 1
122
+
123
+ if n == len(versions_set):
124
+ break
125
+
126
+ def _load_commits_between_refs(
127
+ self, repository: Repository, start_ref: str | None, end_ref: str | None
128
+ ) -> tuple[str, list[Commit]] | tuple[None, None]:
129
+ """
130
+ Load commits between two git references (tags/commits) and its patch URL.
131
+
132
+ NOTE: A potential optimization is to pull all commits between the oldest and
133
+ newest versions in one API call, then associate them with tags client-side.
134
+ This would reduce API calls but increase complexity.
135
+ """
136
+
137
+ if start_ref or not end_ref:
138
+ return None, None
139
+
140
+ compare_url = f"{GITHUB_API}/repos/{repository.owner}/{repository.name}/compare/{start_ref}...{end_ref}"
141
+
142
+ _, compare_data = self._make_github_api_request(compare_url)
143
+
144
+ commits_raw = compare_data.get("commits", [])
145
+ commits = []
146
+
147
+ for commit_data in commits_raw:
148
+ commit = commit_data["commit"]
149
+ author, committer = commit_data.get("author"), commit_data.get("committer")
150
+
151
+ author_user = None
152
+ if author and commit.get("author"):
153
+ author_user = User(
154
+ id=author.get("id"),
155
+ username=author.get("login"),
156
+ profile_url=author.get("html_url"),
157
+ display_name=commit["author"].get("name"),
158
+ email=commit["author"].get("email"),
159
+ )
160
+
161
+ committer_user = None
162
+ if committer and commit.get("committer"):
163
+ committer_user = User(
164
+ id=committer.get("id"),
165
+ username=committer.get("login"),
166
+ profile_url=committer.get("html_url"),
167
+ display_name=commit["committer"].get("name"),
168
+ email=commit["committer"].get("email"),
169
+ )
170
+
171
+ commits.append(
172
+ Commit(
173
+ sha=commit_data["sha"],
174
+ message=commit["message"],
175
+ author=author_user,
176
+ committer=committer_user,
177
+ authored_at=commit.get("author", {}).get("date"),
178
+ committed_at=commit.get("committer", {}).get("date"),
179
+ )
180
+ )
181
+
182
+ return str(compare_data.get("patch_url")), commits
183
+
184
+ def _get_diff_for_version(self, repository: Repository, repository_version: RepositoryVersion) -> RepositoryVersion:
185
+ """
186
+ Pull commits associated with the given RepositoryVersion by
187
+ comparing it to its previous ref.
188
+ """
189
+
190
+ patch_url, commits = self._load_commits_between_refs(
191
+ repository,
192
+ repository_version.ref_previous,
193
+ repository_version.ref_name,
194
+ )
195
+
196
+ repository_version.patch_url = patch_url
197
+ repository_version.commits = commits
198
+
199
+ return repository_version
200
+
201
+ def _load_versions_from_tags(self, repository: Repository, versions_set: set[str]) -> Iterable[RepositoryVersion]:
202
+ """
203
+ Fetch tags from a GitHub repo and convert them to RepositoryVersion objects.
204
+ """
205
+ url = f"{GITHUB_API}/repos/{repository.owner}/{repository.name}/tags"
206
+
207
+ n = 0
208
+ for tag in self._paginate_github_api_request(url):
209
+ version = tag["name"]
210
+ if version in versions_set:
211
+ source_url = f"{repository.html_url}/releases/tag/{tag['name']}"
212
+ yield RepositoryVersion(
213
+ version_source_type=VERSION_DATA_SOURCE_GITHUB_TAGS,
214
+ version=version,
215
+ ref_name=tag["name"],
216
+ release_name=None,
217
+ source_url=source_url,
218
+ commits=None,
219
+ ref_previous=None,
220
+ release_notes=None,
221
+ patch_url=None,
222
+ )
223
+ n += 1
224
+ if n == len(versions_set):
225
+ break
226
+
227
+ def repository_info(self, repository_url: str | None) -> Repository:
228
+ """
229
+ Extract GitHub repository info from a given github URL.
230
+ """
231
+ if repository_url is None:
232
+ raise ValueError("Repository URL cannot be None")
233
+ s = repository_url.strip().removeprefix("git+").removeprefix("https://")
234
+ m = re.search(r"github\.com[:/](?P<owner>[^/]+)/(?P<name>[^/.]+)", s)
235
+
236
+ if not m:
237
+ raise ValueError(f"Invalid GitHub URL: {repository_url}")
238
+
239
+ owner, repo_name = m.group("owner"), m.group("name")
240
+
241
+ # Fetch repository details to get the description
242
+ repo_api_url = f"{GITHUB_API}/repos/{owner}/{repo_name}"
243
+ _, repo_data = self._make_github_api_request(repo_api_url)
244
+
245
+ return Repository(
246
+ provider=RepositoryProvider.PROVIDER_GITHUB,
247
+ name=repo_name,
248
+ owner=owner,
249
+ description=repo_data.get("description"),
250
+ html_url=f"https://github.com/{owner}/{repo_name}",
251
+ )
252
+
253
+ def repository_versions(
254
+ self,
255
+ repository: Repository,
256
+ package_versions: list[PackageVersion],
257
+ comparator: Callable,
258
+ ) -> Iterable[RepositoryVersion]:
259
+ """
260
+ Pull versions info available from the given repository. Github releases
261
+ is the default way to get it, then fallback to tags.
262
+ """
263
+ versions_set = {pv.version for pv in package_versions}
264
+
265
+ # 1. Try loading from releases first, as they contain more metadata.
266
+ releases = list(self._load_releases(repository, versions_set))
267
+
268
+ released_versions = releases
269
+
270
+ # 2. If some versions were not found as releases, fall back to checking tags.
271
+ # This handles cases where a version is tagged but not formally released.
272
+ if len(releases) != len(versions_set):
273
+ released_versions_set = {rv.version for rv in releases}
274
+ missing_versions = versions_set - released_versions_set
275
+ tags_as_versions = self._load_versions_from_tags(repository, missing_versions)
276
+ released_versions = list(itertools.chain(releases, tags_as_versions))
277
+
278
+ # 3. Sort all found versions semantically.
279
+ versions = sort_versions(released_versions, comparator=comparator)
280
+ if not versions:
281
+ return
282
+
283
+ # 4. The first (oldest) version is our baseline; yield it as is.
284
+ yield versions[0]
285
+
286
+ # 5. For all subsequent versions, calculate the commit difference from the previous one.
287
+ for version_from, version_to in itertools.zip_longest(versions, versions[1:]):
288
+ if not version_to:
289
+ break
290
+
291
+ version_to.ref_previous = version_from.ref_name
292
+
293
+ version_to_with_diff = self._get_diff_for_version(repository, version_to)
294
+
295
+ yield version_to_with_diff
@@ -0,0 +1,138 @@
1
+ """
2
+ Interfaces related to external APIs
3
+ """
4
+
5
+ import abc
6
+ from collections.abc import Callable, Iterable
7
+
8
+ from ossiq.domain.common import ProjectPackagesRegistry
9
+ from ossiq.domain.cve import CVE
10
+ from ossiq.domain.package import Package
11
+ from ossiq.domain.packages_manager import PackageManagerType
12
+ from ossiq.domain.project import Project
13
+ from ossiq.settings import Settings
14
+
15
+ from ..domain.repository import Repository
16
+ from ..domain.version import PackageVersion, RepositoryVersion, VersionsDifference
17
+
18
+
19
+ class AbstractSourceCodeProviderApi(abc.ABC):
20
+ """
21
+ Abstract client to communicate with source code repositories like GitHub
22
+ """
23
+
24
+ @abc.abstractmethod
25
+ def repository_info(self, repository_url: str | None) -> Repository:
26
+ raise NotImplementedError
27
+
28
+ @abc.abstractmethod
29
+ def repository_versions(
30
+ self, repository: Repository, package_versions: list[PackageVersion], comparator: Callable
31
+ ) -> Iterable[RepositoryVersion]:
32
+ raise NotImplementedError
33
+
34
+ @abc.abstractmethod
35
+ def __repr__(self):
36
+ raise NotImplementedError
37
+
38
+
39
+ class AbstractPackageRegistryApi(abc.ABC):
40
+ """
41
+ Abstract client to communicate with package registries like PyPi or NPM
42
+ """
43
+
44
+ settings: Settings
45
+ package_registry: ProjectPackagesRegistry
46
+
47
+ @staticmethod
48
+ @abc.abstractmethod
49
+ def compare_versions(v1: str, v2: str) -> int:
50
+ """
51
+ Compare two versions regardless of the registry.
52
+
53
+ Versioning is registry-specific, for example
54
+ JavaScript/NPM follows Semantic Versioning strictly,
55
+ while Python/PyPI ecosystem follows PEP 440.
56
+ """
57
+ raise NotImplementedError
58
+
59
+ @staticmethod
60
+ @abc.abstractmethod
61
+ def difference_versions(v1_str: str | None, v2_str: str | None) -> VersionsDifference:
62
+ """
63
+ Calculate version difference using registry-specific semantics.
64
+
65
+ Categorizes the difference between two versions (major, minor, patch, etc.)
66
+ based on the versioning scheme used by the registry.
67
+
68
+ Args:
69
+ v1: First version string (e.g., installed version)
70
+ v2: Second version string (e.g., latest version)
71
+
72
+ Returns:
73
+ VersionsDifference object with categorized diff index
74
+ """
75
+ raise NotImplementedError
76
+
77
+ @abc.abstractmethod
78
+ def package_info(self, package_name: str) -> Package:
79
+ """
80
+ Get a particular package info
81
+ """
82
+ raise NotImplementedError
83
+
84
+ @abc.abstractmethod
85
+ def package_versions(self, package_name: str) -> Iterable[PackageVersion]:
86
+ """
87
+ Get a particular package versions between what is installed
88
+ currently in the project and the latest version available
89
+ """
90
+ raise NotImplementedError
91
+
92
+ @abc.abstractmethod
93
+ def __repr__(self):
94
+ raise NotImplementedError
95
+
96
+
97
+ class AbstractCveDatabaseApi(abc.ABC):
98
+ """
99
+ Abstract client to communicate with CVEs repositories like osv.dev or github CVE APIs
100
+ """
101
+
102
+ @abc.abstractmethod
103
+ def get_cves_for_package(self, package: Package, version: PackageVersion) -> set[CVE]:
104
+ """
105
+ Method to return a particular CVE info
106
+ """
107
+ raise NotImplementedError
108
+
109
+ @abc.abstractmethod
110
+ def __repr__(self):
111
+ raise NotImplementedError
112
+
113
+
114
+ class AbstractPackageManagerApi(abc.ABC):
115
+ """
116
+ Abstract Package Manager to extract installed versions
117
+ of packages from different package managers.
118
+ """
119
+
120
+ settings: Settings
121
+ package_manager_type: PackageManagerType
122
+ project_path: str
123
+
124
+ @staticmethod
125
+ @abc.abstractmethod
126
+ def has_package_manager(project_path: str) -> bool:
127
+ """
128
+ Detect that package manager is used in a project_path
129
+ """
130
+ pass
131
+
132
+ @abc.abstractmethod
133
+ def project_info(self) -> Project:
134
+ """
135
+ Extract project dependencies using file format from a specific
136
+ package manager.
137
+ """
138
+ pass
@@ -0,0 +1,195 @@
1
+ """
2
+ Implementation of Package Registry API client for NPM
3
+ """
4
+
5
+ from collections.abc import Iterable
6
+
7
+ import requests
8
+ import semver
9
+ from rich.console import Console
10
+
11
+ from ossiq.adapters.api_interfaces import AbstractPackageRegistryApi
12
+ from ossiq.domain.common import ProjectPackagesRegistry
13
+ from ossiq.domain.package import Package
14
+ from ossiq.domain.version import (
15
+ VERSION_DIFF_BUILD,
16
+ VERSION_DIFF_MAJOR,
17
+ VERSION_DIFF_MINOR,
18
+ VERSION_DIFF_PATCH,
19
+ VERSION_DIFF_PRERELEASE,
20
+ VERSION_INVERSED_DIFF_TYPES_MAP,
21
+ VERSION_LATEST,
22
+ VERSION_NO_DIFF,
23
+ PackageVersion,
24
+ VersionsDifference,
25
+ create_version_difference_no_diff,
26
+ )
27
+ from ossiq.settings import Settings
28
+
29
+ console = Console()
30
+
31
+ NPM_REGISTRY = "https://registry.npmjs.org"
32
+ NPM_REGISTRY_FRONT = "https://www.npmjs.com"
33
+
34
+ NPM_DEPENDENCIES_SECTIONS = (
35
+ "dependencies",
36
+ "devDependencies",
37
+ "peerDependencies",
38
+ "optionalDependencies",
39
+ # FIXME: consider pinned versions as well!
40
+ )
41
+
42
+
43
+ class PackageRegistryApiNpm(AbstractPackageRegistryApi):
44
+ """
45
+ Implementation of Package Registry API client for NPM
46
+ """
47
+
48
+ package_registry = ProjectPackagesRegistry.NPM
49
+ settings: Settings
50
+
51
+ @staticmethod
52
+ def compare_versions(v1: str, v2: str) -> int:
53
+ """
54
+ Compare two versions following Semantic Versioning.
55
+ """
56
+ return semver.Version.parse(v1).compare(semver.Version.parse(v2))
57
+
58
+ @staticmethod
59
+ def _calculate_semver_diff_index(v1: semver.Version, v2: semver.Version) -> int:
60
+ """
61
+ Calculate the most significant difference between two semver versions.
62
+
63
+ Compares version components in order of significance:
64
+ 1. Major version
65
+ 2. Minor version
66
+ 3. Patch version
67
+ 4. Prerelease
68
+ 5. Build metadata
69
+
70
+ Args:
71
+ v1: First parsed semver version
72
+ v2: Second parsed semver version
73
+
74
+ Returns:
75
+ Diff index constant indicating the most significant difference level
76
+ """
77
+ if v1.major != v2.major:
78
+ return VERSION_DIFF_MAJOR
79
+
80
+ if v1.minor != v2.minor:
81
+ return VERSION_DIFF_MINOR
82
+
83
+ if v1.patch != v2.patch:
84
+ return VERSION_DIFF_PATCH
85
+
86
+ if v1.prerelease != v2.prerelease:
87
+ return VERSION_DIFF_PRERELEASE
88
+
89
+ if v1.build != v2.build:
90
+ return VERSION_DIFF_BUILD
91
+
92
+ return VERSION_NO_DIFF
93
+
94
+ @staticmethod
95
+ def difference_versions(v1_str: str | None, v2_str: str | None) -> VersionsDifference:
96
+ """
97
+ Calculate version difference using Semantic Versioning (semver) semantics.
98
+
99
+ NPM packages follow strict semver, so we parse and compare major, minor,
100
+ patch, prerelease, and build components.
101
+
102
+ Args:
103
+ v1_str: First version string (e.g., installed version)
104
+ v2_str: Second version string (e.g., latest version)
105
+
106
+ Returns:
107
+ VersionsDifference with categorized diff index
108
+ """
109
+ # Handle None/empty versions
110
+ if not v1_str or not v2_str:
111
+ return create_version_difference_no_diff(v1_str, v2_str)
112
+
113
+ # Optimize: check string equality before parsing
114
+ if v1_str == v2_str:
115
+ return VersionsDifference(
116
+ v1_str, v2_str, VERSION_LATEST, diff_name=VERSION_INVERSED_DIFF_TYPES_MAP[VERSION_LATEST]
117
+ )
118
+
119
+ # Parse versions
120
+ v1 = semver.Version.parse(v1_str)
121
+ v2 = semver.Version.parse(v2_str)
122
+
123
+ # Calculate the difference
124
+ diff_index = PackageRegistryApiNpm._calculate_semver_diff_index(v1, v2)
125
+
126
+ return VersionsDifference(str(v1), str(v2), diff_index, diff_name=VERSION_INVERSED_DIFF_TYPES_MAP[diff_index])
127
+
128
+ def __init__(self, settings: Settings):
129
+ self.settings = settings
130
+
131
+ def __repr__(self):
132
+ return "<PackageRegistryApiNpm instance>"
133
+
134
+ def _make_request(self, path: str, headers: dict | None = None, timeout: int = 15) -> dict:
135
+ """
136
+ Make request and handle retries and errors handling.
137
+ """
138
+ r = requests.get(f"{NPM_REGISTRY}{path}", timeout=timeout, headers=headers)
139
+ r.raise_for_status()
140
+ return r.json()
141
+
142
+ def package_info(self, package_name: str) -> Package:
143
+ """
144
+ Fetch npm info for a given package.
145
+ FIXME: raise custom exception if not found
146
+ """
147
+ response = self._make_request(f"/{package_name}")
148
+ distribution_tags = response.get("dist-tags", {"latest": None, "next": None})
149
+
150
+ return Package(
151
+ registry=ProjectPackagesRegistry.NPM,
152
+ name=response["name"],
153
+ latest_version=distribution_tags.get("latest", None),
154
+ next_version=distribution_tags.get("next", None),
155
+ repo_url=response.get("repository", {}).get("url", None),
156
+ author=response.get("author"),
157
+ homepage_url=response.get("homepage"),
158
+ description=response.get("description"),
159
+ package_url=f"{NPM_REGISTRY_FRONT}/package/{package_name}/",
160
+ )
161
+
162
+ def package_versions(self, package_name: str) -> Iterable[PackageVersion]:
163
+ """
164
+ Fetch npm versions for a given package.
165
+ """
166
+ response = self._make_request(f"/{package_name}")
167
+ # FIXME: raise custom exception if not found
168
+ versions = response.get("versions", [])
169
+ timestamp_map = response.get("time", {})
170
+ unpublished_response = timestamp_map.pop("unpublished", {})
171
+
172
+ # Package version is either published or unpublished
173
+ if unpublished_response:
174
+ unpublished_date_iso = unpublished_response.get("time", None)
175
+ for version in unpublished_response.get("versions", []):
176
+ yield PackageVersion(
177
+ version=version,
178
+ license=None,
179
+ dependencies={},
180
+ package_url=f"{NPM_REGISTRY_FRONT}/package/{package_name}/v/{version}",
181
+ unpublished_date_iso=unpublished_date_iso,
182
+ is_published=False,
183
+ )
184
+ else:
185
+ for version, details in versions.items():
186
+ yield PackageVersion(
187
+ version=version,
188
+ published_date_iso=timestamp_map.get(version, None),
189
+ dependencies=details.get("dependencies", {}),
190
+ license=details.get("license", None),
191
+ runtime_requirements=details.get("engines", None),
192
+ dev_dependencies=details.get("devDependencies", {}),
193
+ description=details.get("description", None),
194
+ package_url=f"{NPM_REGISTRY_FRONT}/package/{package_name}/v/{version}",
195
+ )