Skip to content

Commit eb15ffa

Browse files
authored
Merge pull request #214 from aboutcode-org/add-settings
Add settings
2 parents a0311e0 + addf32f commit eb15ffa

33 files changed

+221
-139
lines changed

CHANGELOG.rst

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,42 @@
11
Changelog
22
=========
33

4+
v0.13.2
5+
-----------
6+
7+
- Speed up downloads with asyncio
8+
9+
- New settings featuring environment variables and .env file to store settings and defaults.
10+
11+
- This also changes the CACHE_THIRDPARTY_DIR environment variable: it used to default first
12+
to ".cache/python_inspector" and if not writable, it would fallback to home
13+
"~/.cache/python_inspector". The new behavior is to only use the "~/.cache/python_inspector"
14+
in the home directory. You can configure this directory to any other value.
15+
16+
- Another change is that pypi.org is no longer systematically added as an index URL for
17+
resolution. Instead the list of configured index URLs is used, and only defaults to pypi.org
18+
if not provided.
19+
20+
- Another change is that it is possible to only use the provided or configured index URLs
21+
and skip other URLs from requirements that are not in these configured URLs.
22+
23+
- Calling utils_pypi.download_sdist or utils_pypi.download_wheel requires a non-empty list
24+
of PypiSimpleRepository.
25+
26+
- python_inspector.utils_pypi.Distribution.download_url is now a method, not a property
27+
28+
- The command line has again a default OS and Python version set.
29+
30+
- Default option values are reported in the JSON results. They were skipped before.
31+
32+
- Drop support for running on Python 3.8. You can still resolve dependencies for Python 3.8.
33+
The default command line tool Python version used for resolution is now 3.9.
34+
35+
- Add support for the latest Python and OS versions.
36+
37+
- Merge latest skeleton and adopt ruff for code formatting.
38+
39+
440
v0.13.1
541
-----------
642

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ packaging==24.2
1414
packvers==21.5
1515
pip-requirements-parser==32.0.1
1616
pkginfo2==30.0.0
17+
pydantic_settings == 2.8.1
18+
pydantic == 2.11.2
1719
pyparsing==3.0.9
1820
PyYAML==6.0
1921
requests==2.28.1

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ install_requires =
7171
packvers >= 21.5
7272
aiohttp >= 3.8
7373
aiofiles >= 23.1
74+
pydantic >= 2.10.0
75+
pydantic_settings >= 2.8.0
7476

7577
[options.packages.find]
7678
where = src

src/python_inspector/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@
77
# See https://aboutcode.org for more information about nexB OSS projects.
88
#
99

10-
DEFAULT_PYTHON_VERSION = "3.8"
10+
from python_inspector import settings
11+
12+
# Initialize global settings
13+
pyinspector_settings = settings.Settings()
14+
15+
settings.create_cache_directory(pyinspector_settings.CACHE_THIRDPARTY_DIR)

src/python_inspector/api.py

Lines changed: 25 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from _packagedcode.pypi import can_process_dependent_package
3030
from _packagedcode.pypi import get_resolved_purl
3131
from python_inspector import dependencies
32+
from python_inspector import pyinspector_settings as settings
3233
from python_inspector import utils
3334
from python_inspector import utils_pypi
3435
from python_inspector.package_data import get_pypi_data_from_purl
@@ -42,8 +43,8 @@
4243
from python_inspector.resolution import get_requirements_from_python_manifest
4344
from python_inspector.utils import Candidate
4445
from python_inspector.utils_pypi import PLATFORMS_BY_OS
45-
from python_inspector.utils_pypi import PYPI_SIMPLE_URL
4646
from python_inspector.utils_pypi import Environment
47+
from python_inspector.utils_pypi import PypiSimpleRepository
4748
from python_inspector.utils_pypi import valid_python_versions
4849

4950

@@ -80,7 +81,7 @@ def resolve_dependencies(
8081
specifiers=tuple(),
8182
python_version=None,
8283
operating_system=None,
83-
index_urls=tuple([PYPI_SIMPLE_URL]),
84+
index_urls: tuple[str, ...] = settings.INDEX_URL,
8485
pdt_output=None,
8586
netrc_file=None,
8687
max_rounds=200000,
@@ -103,7 +104,7 @@ def resolve_dependencies(
103104
linux OS.
104105
105106
Download from the provided PyPI simple index_urls INDEX(s) URLs defaulting
106-
to PyPI.org
107+
to PyPI.org or a configured setting.
107108
"""
108109

109110
if not operating_system:
@@ -148,9 +149,6 @@ def resolve_dependencies(
148149

149150
files = []
150151

151-
if PYPI_SIMPLE_URL not in index_urls:
152-
index_urls = tuple([PYPI_SIMPLE_URL]) + tuple(index_urls)
153-
154152
# requirements
155153
for req_file in requirement_files:
156154
deps = dependencies.get_dependencies_from_requirements(requirements_file=req_file)
@@ -249,29 +247,32 @@ def resolve_dependencies(
249247
if verbose:
250248
printer(f"environment: {environment}")
251249

252-
repos = []
250+
repos_by_url = {}
253251
if not use_pypi_json_api:
254252
# Collect PyPI repos
253+
use_only_confed = settings.USE_ONLY_CONFIGURED_INDEX_URLS
255254
for index_url in index_urls:
256255
index_url = index_url.strip("/")
257-
existing = utils_pypi.DEFAULT_PYPI_REPOS_BY_URL.get(index_url)
258-
if existing:
259-
existing.use_cached_index = use_cached_index
260-
repos.append(existing)
261-
else:
262-
credentials = None
263-
if parsed_netrc:
264-
login, password = utils.get_netrc_auth(index_url, parsed_netrc)
265-
credentials = (
266-
dict(login=login, password=password) if login and password else None
267-
)
268-
repo = utils_pypi.PypiSimpleRepository(
269-
index_url=index_url,
270-
use_cached_index=use_cached_index,
271-
credentials=credentials,
272-
)
273-
repos.append(repo)
256+
if use_only_confed and index_url not in settings.INDEX_URL:
257+
if verbose:
258+
printer(f"Skipping index URL unknown in settings: {index_url!r}")
259+
continue
260+
if index_url in repos_by_url:
261+
continue
262+
263+
credentials = None
264+
if parsed_netrc:
265+
login, password = utils.get_netrc_auth(index_url, parsed_netrc)
266+
if login and password:
267+
credentials = dict(login=login, password=password)
268+
repo = utils_pypi.PypiSimpleRepository(
269+
index_url=index_url,
270+
use_cached_index=use_cached_index,
271+
credentials=credentials,
272+
)
273+
repos_by_url[index_url] = repo
274274

275+
repos = repos_by_url.values()
275276
if verbose:
276277
printer("repos:")
277278
for repo in repos:

src/python_inspector/package_data.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ async def get_wheel_download_urls(
168168
environment=environment,
169169
python_version=python_version,
170170
):
171-
download_urls.append(await wheel.download_url)
171+
download_urls.append(await wheel.download_url(repo))
172172
return download_urls
173173

174174

@@ -186,4 +186,4 @@ async def get_sdist_download_url(
186186
python_version=python_version,
187187
)
188188
if sdist:
189-
return await sdist.download_url
189+
return await sdist.download_url(repo)

src/python_inspector/resolution.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from _packagedcode.pypi import PythonSetupPyHandler
4040
from _packagedcode.pypi import SetupCfgHandler
4141
from _packagedcode.pypi import can_process_dependent_package
42+
from python_inspector import pyinspector_settings as settings
4243
from python_inspector import utils_pypi
4344
from python_inspector.error import NoVersionsFound
4445
from python_inspector.setup_py_live_eval import iter_requirements
@@ -107,7 +108,7 @@ def get_deps_from_distribution(
107108
deps = []
108109
for package_data in handler.parse(location):
109110
dependencies = package_data.dependencies
110-
deps.extend(dependencies)
111+
deps.extend(dependencies=dependencies)
111112
return deps
112113

113114

@@ -211,21 +212,21 @@ async def fetch_and_extract_sdist(
211212
def get_sdist_file_path_from_filename(sdist):
212213
if sdist.endswith(".tar.gz"):
213214
sdist_file = sdist.rstrip(".tar.gz")
214-
with tarfile.open(os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, sdist)) as file:
215+
with tarfile.open(os.path.join(settings.CACHE_THIRDPARTY_DIR, sdist)) as file:
215216
file.extractall(
216-
os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
217+
os.path.join(settings.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
217218
)
218219
elif sdist.endswith(".zip"):
219220
sdist_file = sdist.rstrip(".zip")
220-
with ZipFile(os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, sdist)) as zip:
221+
with ZipFile(os.path.join(settings.CACHE_THIRDPARTY_DIR, sdist)) as zip:
221222
zip.extractall(
222-
os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
223+
os.path.join(settings.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file)
223224
)
224225

225226
else:
226227
raise Exception(f"Unable to extract sdist {sdist}")
227228

228-
return os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file, sdist_file)
229+
return os.path.join(settings.CACHE_THIRDPARTY_DIR, "extracted_sdists", sdist_file, sdist_file)
229230

230231

231232
def get_requirements_from_dependencies(
@@ -444,7 +445,7 @@ async def _get_versions_for_package_from_pypi_json_api(self, name: str) -> List[
444445
api_url = f"https://pypi.org/pypi/{name}/json"
445446
resp = await get_response_async(api_url)
446447
if not resp:
447-
self.versions_by_package[name] = []
448+
return []
448449
releases = resp.get("releases") or {}
449450
return releases.keys() or []
450451

@@ -497,7 +498,7 @@ async def _get_requirements_for_package_from_pypi_simple(
497498

498499
if wheels:
499500
for wheel in wheels:
500-
wheel_location = os.path.join(utils_pypi.CACHE_THIRDPARTY_DIR, wheel)
501+
wheel_location = os.path.join(settings.CACHE_THIRDPARTY_DIR, wheel)
501502
requirements = get_requirements_from_distribution(
502503
handler=PypiWheelHandler,
503504
location=wheel_location,
@@ -596,7 +597,8 @@ def _iter_matches(
596597
name = remove_extras(identifier=identifier)
597598
bad_versions = {c.version for c in incompatibilities[identifier]}
598599
extras = {e for r in requirements[identifier] for e in r.extras}
599-
versions = self.get_versions_for_package(name)
600+
versions = []
601+
versions.extend(self.get_versions_for_package(name=name))
600602

601603
if not versions:
602604
if self.ignore_errors:

src/python_inspector/resolve_cli.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import click
1515

16+
from python_inspector import pyinspector_settings as settings
1617
from python_inspector import utils_pypi
1718
from python_inspector.cli_utils import FileOptionType
1819
from python_inspector.utils import write_output_in_file
@@ -21,8 +22,7 @@
2122

2223
__version__ = "0.13.0"
2324

24-
DEFAULT_PYTHON_VERSION = "38"
25-
PYPI_SIMPLE_URL = "https://pypi.org/simple"
25+
DEFAULT_PYTHON_VERSION = settings.DEFAULT_PYTHON_VERSION
2626

2727

2828
def print_version(ctx, param, value):
@@ -71,6 +71,7 @@ def print_version(ctx, param, value):
7171
"python_version",
7272
type=click.Choice(utils_pypi.valid_python_versions),
7373
metavar="PYVER",
74+
default=settings.DEFAULT_PYTHON_VERSION,
7475
show_default=True,
7576
required=True,
7677
help="Python version to use for dependency resolution. One of "
@@ -82,6 +83,7 @@ def print_version(ctx, param, value):
8283
"operating_system",
8384
type=click.Choice(utils_pypi.PLATFORMS_BY_OS),
8485
metavar="OS",
86+
default=settings.DEFAULT_OS,
8587
show_default=True,
8688
required=True,
8789
help="OS to use for dependency resolution. One of " + ", ".join(utils_pypi.PLATFORMS_BY_OS),
@@ -92,7 +94,7 @@ def print_version(ctx, param, value):
9294
type=str,
9395
metavar="INDEX",
9496
show_default=True,
95-
default=tuple([PYPI_SIMPLE_URL]),
97+
default=tuple(settings.INDEX_URL),
9698
multiple=True,
9799
help="PyPI simple index URL(s) to use in order of preference. "
98100
"This option can be used multiple times.",
@@ -319,9 +321,6 @@ def get_pretty_options(ctx, generic_paths=False):
319321
if getattr(param, "hidden", False):
320322
continue
321323

322-
if value == param.default:
323-
continue
324-
325324
if value in (None, False):
326325
continue
327326

src/python_inspector/settings.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# ScanCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/python-inspector for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
from pathlib import Path
11+
12+
from pydantic import field_validator
13+
from pydantic_settings import BaseSettings
14+
from pydantic_settings import SettingsConfigDict
15+
16+
17+
class Settings(BaseSettings):
18+
"""
19+
Reference: https://docs.pydantic.dev/latest/concepts/pydantic_settings/
20+
A settings object: use it with an .env file and/or environment variables all prefixed with
21+
PYTHON_INSPECTOR_
22+
"""
23+
24+
model_config = SettingsConfigDict(
25+
env_file=".env",
26+
env_file_encoding="utf-8",
27+
env_prefix="PYTHON_INSPECTOR_",
28+
case_sensitive=True,
29+
extra="allow",
30+
)
31+
32+
# the default Python version to use if none is provided
33+
DEFAULT_PYTHON_VERSION: str = "39"
34+
35+
# the default OS to use if none is provided
36+
DEFAULT_OS: str = "linux"
37+
38+
# a list of PyPI simple index URLs. Use a JSON array to represent multiple URLs
39+
INDEX_URL: tuple[str, ...] = ("https://pypi.org/simple",)
40+
41+
# If True, only uses configured INDEX_URLs listed above and ignore other URLs found in requirements
42+
USE_ONLY_CONFIGURED_INDEX_URLS: bool = False
43+
44+
# a path string where to store the cached downloads. Will be created if it does not exists.
45+
CACHE_THIRDPARTY_DIR: str = str(Path(Path.home() / ".cache/python_inspector"))
46+
47+
@field_validator("INDEX_URL")
48+
@classmethod
49+
def validate_index_url(cls, value):
50+
if isinstance(value, str):
51+
return (value,)
52+
elif isinstance(value, (tuple, list)):
53+
return tuple(value)
54+
else:
55+
raise ValueError(f"INDEX_URL must be either a URL or list of URLs: {value!r}")
56+
57+
58+
def create_cache_directory(cache_dir):
59+
cache_dir = Path(cache_dir).expanduser().resolve().absolute()
60+
if not cache_dir.exists():
61+
cache_dir.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)