Skip to content

Commit e92d55c

Browse files
authored
Merge pull request #144 from fviernau/main
Fix resolving requirements with percent encoded characters
2 parents 2018ae3 + 11716a6 commit e92d55c

29 files changed

+722
-482
lines changed

src/python_inspector/api.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,16 @@ class Resolution(NamedTuple):
5858
packages: List[PackageData]
5959
files: List[Dict]
6060

61-
def to_dict(self):
61+
def to_dict(self, generic_paths=False):
62+
files = self.files
63+
if generic_paths:
64+
# clean file paths
65+
for file in files:
66+
path = file["path"]
67+
file["path"] = utils.remove_test_data_dir_variable_prefix(path=path)
68+
6269
return {
63-
"files": self.files,
70+
"files": files,
6471
"packages": [package for package in self.packages],
6572
"resolution": self.resolution,
6673
}
@@ -82,6 +89,7 @@ def resolve_dependencies(
8289
analyze_setup_py_insecurely=False,
8390
prefer_source=False,
8491
printer=print,
92+
generic_paths=False,
8593
):
8694
"""
8795
Resolve the dependencies for the package requirements listed in one or
@@ -141,6 +149,7 @@ def resolve_dependencies(
141149
if PYPI_SIMPLE_URL not in index_urls:
142150
index_urls = tuple([PYPI_SIMPLE_URL]) + tuple(index_urls)
143151

152+
# requirements
144153
for req_file in requirement_files:
145154
deps = dependencies.get_dependencies_from_requirements(requirements_file=req_file)
146155
for extra_data in dependencies.get_extra_data_from_requirements(requirements_file=req_file):
@@ -149,6 +158,9 @@ def resolve_dependencies(
149158
package_data = [
150159
pkg_data.to_dict() for pkg_data in PipRequirementsFileHandler.parse(location=req_file)
151160
]
161+
if generic_paths:
162+
req_file = utils.remove_test_data_dir_variable_prefix(path=req_file)
163+
152164
files.append(
153165
dict(
154166
type="file",
@@ -157,10 +169,12 @@ def resolve_dependencies(
157169
)
158170
)
159171

172+
# specs
160173
for specifier in specifiers:
161174
dep = dependencies.get_dependency(specifier=specifier)
162175
direct_dependencies.append(dep)
163176

177+
# setup.py
164178
if setup_py_file:
165179
package_data = list(PythonSetupPyHandler.parse(location=setup_py_file))
166180
assert len(package_data) == 1
@@ -203,6 +217,8 @@ def resolve_dependencies(
203217

204218
package_data.dependencies = setup_py_file_deps
205219
file_package_data = [package_data.to_dict()]
220+
if generic_paths:
221+
setup_py_file = utils.remove_test_data_dir_variable_prefix(path=setup_py_file)
206222
files.append(
207223
dict(
208224
type="file",
@@ -294,6 +310,9 @@ def resolve_dependencies(
294310
)
295311

296312

313+
resolver_api = resolve_dependencies
314+
315+
297316
def resolve(
298317
direct_dependencies,
299318
environment,

src/python_inspector/resolve_cli.py

+138-22
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
import click
1515

1616
from python_inspector import utils_pypi
17-
from python_inspector.api import resolve_dependencies as resolver_api
1817
from python_inspector.cli_utils import FileOptionType
1918
from python_inspector.utils import write_output_in_file
2019

@@ -52,9 +51,9 @@ def print_version(ctx, param, value):
5251
"setup_py_file",
5352
type=click.Path(exists=True, readable=True, path_type=str, dir_okay=False),
5453
metavar="SETUP-PY-FILE",
54+
multiple=False,
5555
required=False,
56-
help="Path to setuptools setup.py file listing dependencies and metadata. "
57-
"This option can be used multiple times.",
56+
help="Path to setuptools setup.py file listing dependencies and metadata.",
5857
)
5958
@click.option(
6059
"--spec",
@@ -74,7 +73,8 @@ def print_version(ctx, param, value):
7473
metavar="PYVER",
7574
show_default=True,
7675
required=True,
77-
help="Python version to use for dependency resolution.",
76+
help="Python version to use for dependency resolution. One of "
77+
+ ", ".join(utils_pypi.PYTHON_DOT_VERSIONS_BY_VER.values()),
7878
)
7979
@click.option(
8080
"-o",
@@ -84,7 +84,7 @@ def print_version(ctx, param, value):
8484
metavar="OS",
8585
show_default=True,
8686
required=True,
87-
help="OS to use for dependency resolution.",
87+
help="OS to use for dependency resolution. One of " + ", ".join(utils_pypi.PLATFORMS_BY_OS),
8888
)
8989
@click.option(
9090
"--index-url",
@@ -123,21 +123,23 @@ def print_version(ctx, param, value):
123123
metavar="NETRC-FILE",
124124
hidden=True,
125125
required=False,
126-
help="Netrc file to use for authentication. ",
126+
help="Netrc file to use for authentication.",
127127
)
128128
@click.option(
129129
"--max-rounds",
130130
"max_rounds",
131131
hidden=True,
132132
type=int,
133133
default=200000,
134-
help="Increase the max rounds whenever the resolution is too deep",
134+
help="Increase the maximum number of resolution rounds. "
135+
"Use in the rare cases where the resolution graph is very deep.",
135136
)
136137
@click.option(
137138
"--use-cached-index",
138139
is_flag=True,
139140
hidden=True,
140-
help="Use cached on-disk PyPI simple package indexes and do not refetch if present.",
141+
help="Use cached on-disk PyPI simple package indexes "
142+
"and do not refetch package index if cache is present.",
141143
)
142144
@click.option(
143145
"--use-pypi-json-api",
@@ -148,20 +150,19 @@ def print_version(ctx, param, value):
148150
@click.option(
149151
"--analyze-setup-py-insecurely",
150152
is_flag=True,
151-
help="Enable collection of requirements in setup.py that compute these"
152-
" dynamically. This is an insecure operation as it can run arbitrary code.",
153+
help="Enable collection of requirements in setup.py that compute these "
154+
"dynamically. This is an insecure operation as it can run arbitrary code.",
153155
)
154156
@click.option(
155157
"--prefer-source",
156158
is_flag=True,
157-
help="Prefer source distributions over binary distributions"
158-
" if no source distribution is available then binary distributions are used",
159+
help="Prefer source distributions over binary distributions if no source "
160+
"distribution is available then binary distributions are used",
159161
)
160162
@click.option(
161163
"--verbose",
162164
is_flag=True,
163-
hidden=True,
164-
help="Enable debug output.",
165+
help="Enable verbose debug output.",
165166
)
166167
@click.option(
167168
"-V",
@@ -173,6 +174,13 @@ def print_version(ctx, param, value):
173174
help="Show the version and exit.",
174175
)
175176
@click.help_option("-h", "--help")
177+
@click.option(
178+
"--generic-paths",
179+
is_flag=True,
180+
hidden=True,
181+
help="Use generic or truncated paths in the JSON output header and files sections. "
182+
"Used only for testing to avoid absolute paths and paths changing at each run.",
183+
)
176184
def resolve_dependencies(
177185
ctx,
178186
requirement_files,
@@ -190,6 +198,7 @@ def resolve_dependencies(
190198
analyze_setup_py_insecurely=False,
191199
prefer_source=False,
192200
verbose=TRACE,
201+
generic_paths=False,
193202
):
194203
"""
195204
Resolve the dependencies for the package requirements listed in one or
@@ -212,6 +221,8 @@ def resolve_dependencies(
212221
213222
python-inspector --spec "flask==2.1.2" --json -
214223
"""
224+
from python_inspector.api import resolve_dependencies as resolver_api
225+
215226
if not (json_output or pdt_output):
216227
click.secho("No output file specified. Use --json or --json-pdt.", err=True)
217228
ctx.exit(1)
@@ -220,12 +231,7 @@ def resolve_dependencies(
220231
click.secho("Only one of --json or --json-pdt can be used.", err=True)
221232
ctx.exit(1)
222233

223-
options = [f"--requirement {rf}" for rf in requirement_files]
224-
options += [f"--specifier {sp}" for sp in specifiers]
225-
options += [f"--index-url {iu}" for iu in index_urls]
226-
options += [f"--python-version {python_version}"]
227-
options += [f"--operating-system {operating_system}"]
228-
options += ["--json <file>"]
234+
options = get_pretty_options(ctx, generic_paths=generic_paths)
229235

230236
notice = (
231237
"Dependency tree generated with python-inspector.\n"
@@ -260,23 +266,133 @@ def resolve_dependencies(
260266
analyze_setup_py_insecurely=analyze_setup_py_insecurely,
261267
printer=click.secho,
262268
prefer_source=prefer_source,
269+
generic_paths=generic_paths,
263270
)
271+
272+
files = resolution_result.files or []
264273
output = dict(
265274
headers=headers,
266-
files=resolution_result.files,
275+
files=files,
267276
packages=resolution_result.packages,
268277
resolved_dependencies_graph=resolution_result.resolution,
269278
)
270279
write_output_in_file(
271280
output=output,
272281
location=json_output or pdt_output,
273282
)
274-
except Exception as exc:
283+
except Exception:
275284
import traceback
276285

277286
click.secho(traceback.format_exc(), err=True)
278287
ctx.exit(1)
279288

280289

290+
def get_pretty_options(ctx, generic_paths=False):
291+
"""
292+
Return a sorted list of formatted strings for the selected CLI options of
293+
the `ctx` Click.context, putting arguments first then options:
294+
295+
["~/some/path", "--license", ...]
296+
297+
Skip options that are hidden or flags that are not set.
298+
If ``generic_paths`` is True, click.File and click.Path parameters are made
299+
"generic" replacing their value with a placeholder. This is used mostly for
300+
testing.
301+
"""
302+
303+
args = []
304+
options = []
305+
306+
param_values = ctx.params
307+
for param in ctx.command.params:
308+
name = param.name
309+
value = param_values.get(name)
310+
311+
if param.is_eager:
312+
continue
313+
314+
if getattr(param, "hidden", False):
315+
continue
316+
317+
if value == param.default:
318+
continue
319+
320+
if value in (None, False):
321+
continue
322+
323+
if value in (tuple(), []):
324+
# option with multiple values, the value is a emoty tuple
325+
continue
326+
327+
# opts is a list of CLI options as in "--verbose": the last opt is
328+
# the CLI option long form by convention
329+
cli_opt = param.opts[-1]
330+
331+
if not isinstance(value, (tuple, list)):
332+
value = [value]
333+
334+
for val in value:
335+
val = get_pretty_value(param_type=param.type, value=val, generic_paths=generic_paths)
336+
337+
if isinstance(param, click.Argument):
338+
args.append(val)
339+
else:
340+
# an option
341+
if val is True:
342+
# mere flag... do not add the "true" value
343+
options.append(f"{cli_opt}")
344+
else:
345+
options.append(f"{cli_opt} {val}")
346+
347+
return sorted(args) + sorted(options)
348+
349+
350+
def get_pretty_value(param_type, value, generic_paths=False):
351+
"""
352+
Return pretty formatted string extracted from a parameter ``value``.
353+
Make paths generic (by using a placeholder or truncating the path) if
354+
``generic_paths`` is True.
355+
"""
356+
if isinstance(param_type, (click.Path, click.File)):
357+
return get_pretty_path(param_type, value, generic_paths)
358+
359+
elif not (value is None or isinstance(value, (str, bytes, tuple, list, dict, bool))):
360+
# coerce to string for non-basic types
361+
return repr(value)
362+
363+
else:
364+
return value
365+
366+
367+
def get_pretty_path(param_type, value, generic_paths=False):
368+
"""
369+
Return a pretty path value for a Path or File option. Truncate the path or
370+
use a placeholder as needed if ``generic_paths`` is True. Used for testing.
371+
"""
372+
from python_inspector.utils import remove_test_data_dir_variable_prefix
373+
374+
if value == "-":
375+
return value
376+
377+
if isinstance(param_type, click.Path):
378+
if generic_paths:
379+
return remove_test_data_dir_variable_prefix(path=value)
380+
return value
381+
382+
elif isinstance(param_type, click.File):
383+
# the value cannot be displayed as-is as this may be an opened file-
384+
# like object
385+
vname = getattr(value, "name", None)
386+
if not vname:
387+
return "<file>"
388+
else:
389+
value = vname
390+
391+
if generic_paths:
392+
return remove_test_data_dir_variable_prefix(path=value, placeholder="<file>")
393+
394+
return value
395+
396+
281397
if __name__ == "__main__":
282398
resolve_dependencies()

src/python_inspector/utils.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
import json
1313
import os
14-
import tempfile
1514
from typing import Dict
1615
from typing import List
1716
from typing import NamedTuple
@@ -73,3 +72,15 @@ def get_response(url: str) -> Dict:
7372
resp = requests.get(url)
7473
if resp.status_code == 200:
7574
return resp.json()
75+
76+
77+
def remove_test_data_dir_variable_prefix(path, placeholder="<file>"):
78+
"""
79+
Return a clean path, removing variable test path prefix or using a ``placeholder``.
80+
Used for testing to ensure that results are stable across runs.
81+
"""
82+
if "tests/data/" in path:
83+
_junk, test_dir, cleaned = path.partition("tests/data/")
84+
return f"{test_dir}{cleaned}"
85+
else:
86+
return placeholder

0 commit comments

Comments
 (0)