Skip to content

Commit f69e0ee

Browse files
author
Daniel Zagaynov
committed
Merge development branch 'kotopes-develop'
2 parents 89f4d18 + 5a423d5 commit f69e0ee

File tree

7 files changed

+215
-28
lines changed

7 files changed

+215
-28
lines changed

Diff for: README.md

+27
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,33 @@ But it is possible to ignore all deps, that are hidden inside contexts:
155155
%
156156
```
157157

158+
#### Matching dependencies with environment
159+
Imagine you write your big project, all your dependencies (including building and testing dependencies) are installed to your virtual (or real) environment. So you need to detect your runnning dependencies and match them to packages, installed to your environment, and get **requirements.txt** file, which you can include in your package. For such cases there is **--inspect_env** option:
160+
161+
```shell
162+
% py3req --inspect_env --verbose src
163+
% cat requirements.txt
164+
numpy==2.2.1
165+
```
166+
167+
As you can see, **py3req** saves matched dependencies to **requirements.txt** file.
168+
169+
Now we can get your testing dependencies:
170+
```shell
171+
% py3req --inspect_env --verbose tests
172+
py3prov:INFO: bad name for provides from path:config-3.12-x86_64-linux-gnu
173+
py3prov:INFO: bad name for provides from path:numpy.libs
174+
py3req:/tmp/project/tests/test1.py: "unittest" lines:[1] is possibly a self-providing dependency, skip it
175+
The following deps:pytest was satisfied by package:pytest==8.3.4
176+
% cat requirements.txt
177+
pytest==8.3.4
178+
```
179+
180+
The difference between running **py3req** with option **--inspect_env** and **pip3 freeze** is that the last command lists all packages installed to your environment (including their dependencies). But **py3req** just finds all dependencies of given sources and can match it to the installed packages.
181+
182+
Also there is an extra option for **--inspect_env** which is called **--env_path**. This options lets you to specify path to your environment (where your packages are installed). It is usefull for **CI** or something like that, but by default **py3req** checks your [purelib](https://docs.python.org/3/library/sysconfig.html#installation-paths) and [platlib](https://docs.python.org/3/library/sysconfig.html#installation-paths), so you can skip this option.
183+
184+
158185
Other options are little bit specific, but there is clear **--help** option output. Please, check it.
159186

160187

Diff for: pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "py3dephell"
3-
version = "0.3.1"
3+
version = "0.4.1"
44
authors = [
55
{name = "Daniel Zagaynov", email = "[email protected]"},
66
]

Diff for: src/py3dephell/py3prov.py

+62-8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
import re
55
import sys
6+
import csv
67
import argparse
78
import sysconfig
89
from pathlib import Path
@@ -37,7 +38,7 @@ def processing_pth(path):
3738
new_names = [os.path.join(path, new_dir) for new_dir in new_names]
3839
return new_names
3940
except FileNotFoundError:
40-
print(f'py3prov: No such file or directory:{path}', file=sys.stderr)
41+
print(f'py3prov:INFO: No such file or directory:{path}', file=sys.stderr)
4142
return []
4243

4344

@@ -79,7 +80,7 @@ def create_provides_from_path(path, prefixes=sys.path, abs_mode=False,
7980
path = Path(path.as_posix().replace(pref + '/', ''))
8081

8182
if not path:
82-
raise ValueError('py3prov.create_provides_from_path: path cannot be empty (possibly it was cut by pref)')
83+
raise ValueError('py3prov.create_provides_from_path: path cannot be empty (possibly it was cut by prefix)')
8384

8485
top_package_flag = False
8586

@@ -101,7 +102,7 @@ def create_provides_from_path(path, prefixes=sys.path, abs_mode=False,
101102

102103
if '.' in parts[-1]:
103104
if parts[-1] not in _bad_provides and verbose:
104-
print(f'py3prov: bad name for provides from path:{path.as_posix()}', file=sys.stderr)
105+
print(f'py3prov:INFO: bad name for provides from path:{path}', file=sys.stderr)
105106
_bad_provides.add(parts[-1])
106107

107108
if abs_mode and (all([part.isidentifier() for part in parts]) or not skip_wrong_names):
@@ -181,7 +182,7 @@ def module_detector(path, prefixes, modules=[], verbose_mode=True):
181182
if pref and (pref := os.path.normpath(pref)) and path.startswith(pref + '/') and pref != os.path.normpath(path):
182183
module = re.match(r'%s\/([^\/]+)' % re.escape(pref), path).groups()[0]
183184
if verbose_mode and module not in modules:
184-
print(f'py3prov: detected potential module:{module}', file=sys.stderr)
185+
print(f'py3prov:INFO: detected potential module:{module}', file=sys.stderr)
185186
return pref, module
186187
return None, None
187188

@@ -202,20 +203,20 @@ def pth_detector(pathes, verbose_mode=False):
202203
path = Path(path)
203204
if not path.exists():
204205
if verbose_mode:
205-
print(f'Path {path} does not exist, skip it', file=sys.stderr)
206+
print(f'py3prov:WARNING: Path {path} does not exist, skip it', file=sys.stderr)
206207
continue
207208
if path.suffix == '.pth':
208209
if verbose_mode:
209-
print(f'Detected .pth file:{path.absolute().as_posix()}', file=sys.stderr)
210+
print(f'py3prov:INFO: Detected .pth file:{path.absolute().as_posix()}', file=sys.stderr)
210211
new_prefixes += processing_pth(path.absolute().as_posix())
211212
elif path.is_dir():
212213
for item in path.iterdir():
213214
if item.suffix == '.pth':
214-
print(f'Detected .pth file:{item.absolute().as_posix()}', file=sys.stderr)
215+
print(f'py3prov:INFO: Detected .pth file:{item.absolute().as_posix()}', file=sys.stderr)
215216
new_prefixes += processing_pth(item.absolute().as_posix())
216217
else:
217218
if verbose_mode:
218-
print(f'Path {path} is not a directory or .pth file, skip it', file=sys.stderr)
219+
print(f'py3prov:INFO: Path {path} is not a directory or .pth file, skip it', file=sys.stderr)
219220
return new_prefixes
220221

221222

@@ -265,6 +266,59 @@ def files_filter(files, prefixes=sys.path, only_prefix=False,
265266
return files_dict
266267

267268

269+
def _process_rec_file(file, verbose=False):
270+
try:
271+
with open(file) as f:
272+
return list(filter(lambda p: suff in [so_suffix, shlib_suffix, soabi, soabi3, '.py', abi3]
273+
if (suff := Path(p).suffix) is not None else True,
274+
map(lambda row: row[0], csv.reader(f))))
275+
except (FileNotFoundError, PermissionError) as err:
276+
if verbose:
277+
print(f"py3prov:WARNING: Failed to proceed {file} due to {err}", file=sys.stderr)
278+
279+
280+
def _genprov_from_recs(record, verbose=False):
281+
if (recs := _process_rec_file(record, verbose=verbose)) is not None:
282+
return sum(map(lambda path: create_provides_from_path(path, abs_mode=True,
283+
skip_namespace_pkgs=False, verbose=verbose),
284+
recs), start=[])
285+
286+
287+
def genprov_from_env(paths=[], verbose=False):
288+
"""
289+
Generate provides from installed to environment wheels according to their .dist-info/RECORD file
290+
291+
:param paths: paths where py3prov should look for wheels.
292+
If not set, wheels will be searched according to purelib and platlib
293+
:type paths: list()
294+
:param verbose: make it verbose
295+
:type verbose: Bool
296+
:return: dictionary from package name, package version and its provides
297+
:rtype: dict
298+
"""
299+
paths = set([sysconfig.get_paths()['purelib'], sysconfig.get_paths()['platlib']]) if paths == [] else paths
300+
pattern = re.compile("([^/]+)-([^-]+)\.dist-info")
301+
pkg_ver_provs = {}
302+
for dist_inf, recs in _find_dist_info_recs(paths, verbose):
303+
if (fnd := pattern.search(dist_inf.name)) is not None:
304+
pkg, ver = fnd.groups()
305+
provs = set(_genprov_from_recs(recs, verbose=verbose))
306+
pkg_ver_provs[(pkg, ver)] = provs
307+
return pkg_ver_provs
308+
309+
310+
def _find_dist_info_recs(paths, verbose=False):
311+
for direc in paths:
312+
for pkg in Path(direc).iterdir():
313+
if (dist_inf := Path(pkg)).is_dir() and dist_inf.name.endswith(".dist-info"):
314+
if (rec := dist_inf.joinpath("RECORD")).exists():
315+
yield dist_inf, rec
316+
elif verbose:
317+
print("py3prov:WARNING: Found dist-info, which does not"
318+
f" provide RECORD file:{dist_inf.absolute().as_posix()}",
319+
file=sys.stderr)
320+
321+
268322
def generate_provides(files, prefixes=sys.path, skip_pth=False, only_prefix=False,
269323
deep_search=False, abs_mode=False, verbose=True,
270324
skip_wrong_names=True, skip_namespace_pkgs=True):

Diff for: src/py3dephell/py3req.py

+58-11
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import argparse
1010
import pathlib
1111
import sysconfig
12-
from .py3prov import generate_provides, search_for_provides
12+
from .py3prov import generate_provides, search_for_provides, genprov_from_env
1313

1414

1515
def _remove_reduntant_args(func):
@@ -296,6 +296,7 @@ def filter_requirements(file, deps, provides=[], only_top_module=[], ignore_list
296296
:rtype: set[str]
297297
'''
298298
dependencies = set()
299+
299300
for dep, lines in deps.items():
300301
if dep in ignore_list:
301302
if verbose:
@@ -311,14 +312,15 @@ def filter_requirements(file, deps, provides=[], only_top_module=[], ignore_list
311312
if only_top_module:
312313
dep = dep.split('.')[0]
313314
dependencies.add(dep)
315+
314316
return dependencies
315317

316318

317319
@_remove_reduntant_args
318320
def generate_requirements(files, add_prov_path=[], prefixes=sys.path,
319321
ignore_list=sys.builtin_module_names, read_prov_from_file=None,
320322
skip_subs=True, only_external_deps=False, only_top_module=False,
321-
exclude_stdlib=False, stderr=sys.stderr, verbose=True):
323+
exclude_stdlib=False, inspect_env=False, env_path=[], stderr=sys.stderr, verbose=True):
322324
'''
323325
Generate dependencies for given file-list, filter them through detected provides and return in specified format.
324326
@@ -340,6 +342,10 @@ def generate_requirements(files, add_prov_path=[], prefixes=sys.path,
340342
:type only_top_module: Bool
341343
:param exclude_stdlib: exclude from dependencies standard lib provides
342344
:type exclude_stdlib: Bool
345+
:param inspect_env: inspect environment for installed packages and match with them requirements
346+
:type inspect_env: Bool
347+
:param env_path: path to the environment (useful for inspect_env option)
348+
:type env_path: [str]
343349
:param stderr: messages output
344350
:type stderr: io
345351
:param verbose: verbose flag
@@ -351,7 +357,11 @@ def generate_requirements(files, add_prov_path=[], prefixes=sys.path,
351357
abs_provides = set()
352358
add_provides = set()
353359
modules = {}
354-
dependencies = {}
360+
if not inspect_env:
361+
dependencies = {}
362+
else:
363+
dependencies = set()
364+
tmp_dependencies = set()
355365

356366
if read_prov_from_file:
357367
with open(read_prov_from_file) as f:
@@ -383,10 +393,16 @@ def generate_requirements(files, add_prov_path=[], prefixes=sys.path,
383393
# This module is provided by different real modules which are platform specific, such as posixpath.py
384394
add_provides.add("os.path")
385395

396+
if inspect_env:
397+
env_path = set([sysconfig.get_paths()['purelib'],
398+
sysconfig.get_paths()['platlib']]) if env_path == [] else env_path
399+
env_provides = genprov_from_env(paths=env_path, verbose=verbose)
400+
386401
for file in files:
387402
if file.endswith('.so'):
388403
if (dep := catch_so(file, stderr)):
389-
dependencies[file] = set(), set(), set(), set([dep])
404+
if not inspect_env:
405+
dependencies[file] = set(), set(), set(), set([dep])
390406
continue
391407

392408
abs_deps, rel_deps, adv_deps, skip =\
@@ -412,7 +428,26 @@ def generate_requirements(files, add_prov_path=[], prefixes=sys.path,
412428

413429
filter_requirements(file, skip, skip_flag=True, stderr=stderr, verbose=verbose)
414430

415-
dependencies[file] = abs_deps, rel_deps, adv_deps, set()
431+
if not inspect_env:
432+
dependencies[file] = abs_deps, rel_deps, adv_deps, set()
433+
else:
434+
tmp_dependencies |= abs_deps | rel_deps | adv_deps
435+
436+
if inspect_env:
437+
for pkg_ver, provs in env_provides.items():
438+
if (matched := provs.intersection(tmp_dependencies)):
439+
tmp_dependencies.difference_update(matched)
440+
dependencies.add("==".join(pkg_ver))
441+
if verbose:
442+
print(f"The following deps:{",".join(matched)} was satisfied by package:{"==".join(pkg_ver)}",
443+
file=sys.stderr)
444+
if not tmp_dependencies:
445+
break
446+
else:
447+
if verbose and tmp_dependencies:
448+
print("WARNING! Dependencies not matched to any package"
449+
f" in your environment:{",".join(tmp_dependencies)}",
450+
file=sys.stderr)
416451

417452
return dependencies
418453

@@ -423,7 +458,7 @@ def main():
423458
args.add_argument('--add_prov_path', default="",
424459
help='List of additional paths for provides (separated by ":")')
425460
args.add_argument('--prefixes',
426-
help='Prefixes that will be removed from full'
461+
help='Prefixes that will be removed from fully '
427462
'qualified name for relative import (string separated by ":")')
428463
args.add_argument('--ignore_list', default="",
429464
help='List of dependencies that should be ignored (separated by ":")')
@@ -438,6 +473,12 @@ def main():
438473
help='For dependency like a.b skip b')
439474
args.add_argument('--include_stdlib', action='store_true',
440475
help='Exclude dependencies that are provided by installed python3 standart library')
476+
args.add_argument("--inspect_env", action="store_true",
477+
help="Inspect environment for installed packages and "
478+
+ "match required symbols to installed packages")
479+
args.add_argument("--env_path", default="",
480+
help='Set path to the environment with installed packages (string separated by ":"). '
481+
+ "By default set to purelib and platlib")
441482
args.add_argument('--verbose', action='store_true',
442483
help='Verbose stderr')
443484
args.add_argument('input', nargs='*',
@@ -458,20 +499,26 @@ def main():
458499
if not args.include_built_in:
459500
ignore_list += sys.builtin_module_names
460501

502+
env_path = [p for p in args.env_path.split(":") if p]
503+
461504
dependencies = generate_requirements(files=args.input, add_prov_path=args.add_prov_path.split(":"),
462505
ignore_list=ignore_list,
463506
read_prov_from_file=args.read_prov_from_file,
464507
skip_subs=True, prefixes=prefixes,
465508
only_external_deps=args.exclude_hidden_deps,
466509
only_top_module=args.only_top_module,
467510
exclude_stdlib=not args.include_stdlib,
511+
inspect_env=args.inspect_env, env_path=env_path,
468512
verbose=args.verbose)
469513

470-
for file, deps in dependencies.items():
471-
if any(deps) and args.verbose:
472-
print(f'{file}:{" ".join([" ".join(req) for req in deps if req])}')
473-
elif any(deps):
474-
print('\n'.join(['\n'.join(req) for req in deps if req]))
514+
if not args.inspect_env:
515+
for file, deps in dependencies.items():
516+
if any(deps) and args.verbose:
517+
print(f'{file}:{" ".join([" ".join(req) for req in deps if req])}')
518+
elif any(deps):
519+
print('\n'.join(['\n'.join(req) for req in deps if req]))
520+
else:
521+
print("\n".join(dependencies))
475522

476523

477524
if __name__ == '__main__':

Diff for: tests/package.py

+31
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import csv
2+
import hashlib
13
import pathlib
4+
from shutil import rmtree
25

36

47
def prepare_package(path, name, namespace_pkg=False, w_pth=False, level=0):
@@ -47,3 +50,31 @@ def generate_pymodule(path, name, text=None):
4750
p = pathlib.Path(path).joinpath(f'{name}.py')
4851
p.write_text(text)
4952
return [p] + generate_somodule(path, f'{name}_lib')
53+
54+
55+
def generate_install_wheel(path, name, version, broken=False):
56+
"""
57+
Generate and install dummy wheel
58+
"""
59+
p = pathlib.Path(path).joinpath(name)
60+
try:
61+
p.mkdir(parents=True)
62+
except FileExistsError:
63+
rmtree(p)
64+
p.mkdir(parents=True)
65+
66+
for module, function in zip(["module_1.py", "module_2.py", "__init__.py"], ["func_1", "func_2", "func"]):
67+
p.joinpath(module).write_text(f"def {function}:\n\tpass")
68+
69+
d_io = pathlib.Path(path).joinpath(f"{name}-{version}.dist-info")
70+
try:
71+
d_io.mkdir(parents=True)
72+
except FileExistsError:
73+
rmtree(d_io)
74+
d_io.mkdir(parents=True)
75+
if not broken:
76+
with open(d_io.joinpath("RECORD"), "w", newline="") as csv_file:
77+
csv_writer = csv.writer(csv_file, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL)
78+
for module, function in zip(["module_1.py", "module_2.py", "__init__.py"], ["func_1", "func_2", "func"]):
79+
text = f"def {function}:\n\tpass"
80+
csv_writer.writerow([f"{name}/{module}", hashlib.sha256(text.encode()).hexdigest(), 666])

0 commit comments

Comments
 (0)