Skip to content

Commit 33f87aa

Browse files
authored
Merge pull request #17 from IBM/SetupTools
Setup tools
2 parents d2b65f3 + 29971c0 commit 33f87aa

File tree

6 files changed

+261
-25
lines changed

6 files changed

+261
-25
lines changed

import_tracker/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
# Local
7+
from . import setup_tools
78
from .import_tracker import (
89
BEST_EFFORT,
910
LAZY,

import_tracker/import_tracker.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# Standard
77
from contextlib import contextmanager
88
from types import ModuleType
9-
from typing import Dict, List, Optional
9+
from typing import Dict, Iterable, List, Optional
1010
import copy
1111
import importlib
1212
import inspect
@@ -128,26 +128,7 @@ def get_required_imports(name: str) -> List[str]:
128128

129129
def get_required_packages(name: str) -> List[str]:
130130
"""Get the set of installable packages required by this names module"""
131-
# Lazily create the global mapping
132-
global _module_to_pkg
133-
if _module_to_pkg is None:
134-
_module_to_pkg = _map_modules_to_package_names()
135-
136-
# Get all required imports
137-
required_modules = get_required_imports(name)
138-
139-
# Merge the required packages for each
140-
required_pkgs = set()
141-
for mod in required_modules:
142-
# If there is a known mapping, use it
143-
if mod in _module_to_pkg:
144-
required_pkgs.update(_module_to_pkg[mod])
145-
146-
# Otherwise, assume that the name of the module is itself the name of
147-
# the package
148-
else:
149-
required_pkgs.add(mod)
150-
return sorted(list(required_pkgs))
131+
return _get_required_packages_for_imports(get_required_imports(name))
151132

152133

153134
def get_tracked_modules(prefix: str = "") -> List[str]:
@@ -299,7 +280,7 @@ def _map_modules_to_package_names():
299280
),
300281
):
301282
modules_to_package_names.setdefault(modname, set()).add(
302-
package_name
283+
_standardize_package_name(package_name)
303284
)
304285

305286
return modules_to_package_names
@@ -335,3 +316,31 @@ def _load_static_tracker():
335316
with open(static_tracker, "r") as handle:
336317
global _module_dep_mapping
337318
_module_dep_mapping.update(json.load(handle))
319+
320+
321+
def _standardize_package_name(raw_package_name):
322+
"""Helper to convert the arbitrary ways packages can be represented to a
323+
common (matchable) representation
324+
"""
325+
return raw_package_name.strip().lower().replace("-", "_")
326+
327+
328+
def _get_required_packages_for_imports(imports: Iterable[str]) -> List[str]:
329+
"""Get the set of installable packages required by this list of imports"""
330+
# Lazily create the global mapping
331+
global _module_to_pkg
332+
if _module_to_pkg is None:
333+
_module_to_pkg = _map_modules_to_package_names()
334+
335+
# Merge the required packages for each
336+
required_pkgs = set()
337+
for mod in imports:
338+
# If there is a known mapping, use it
339+
if mod in _module_to_pkg:
340+
required_pkgs.update(_module_to_pkg[mod])
341+
342+
# Otherwise, assume that the name of the module is itself the name of
343+
# the package
344+
else:
345+
required_pkgs.add(mod)
346+
return sorted(list(required_pkgs))

import_tracker/setup_tools.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""
2+
This module holds tools for libraries to use when definint requirements and
3+
extras_require sets in a setup.py
4+
"""
5+
6+
# Standard
7+
from functools import reduce
8+
from typing import Dict, List, Tuple
9+
import importlib
10+
import logging
11+
import re
12+
import sys
13+
14+
# Local
15+
from .import_tracker import (
16+
_get_required_packages_for_imports,
17+
_standardize_package_name,
18+
get_required_packages,
19+
get_tracked_modules,
20+
)
21+
22+
# Regex for parsing requirements
23+
req_split_expr = re.compile(r"[=><!~\[]")
24+
25+
# Shared logger
26+
log = logging.getLogger("SETUP")
27+
28+
29+
def _map_requirements(declared_dependencies, dependency_set):
30+
"""Given the declared dependencies from requirements.txt and the given
31+
programmatic dependency set, return the subset of declared dependencies that
32+
matches the dependency set
33+
"""
34+
return sorted(
35+
[
36+
declared_dependencies[dep.replace("-", "_")]
37+
for dep in dependency_set
38+
if dep.replace("-", "_") in declared_dependencies
39+
]
40+
)
41+
42+
43+
def parse_requirements(
44+
requirements_file: str,
45+
library_name: str,
46+
) -> Tuple[List[str], Dict[str, List[str]]]:
47+
"""This helper uses the lists of required modules and parameters for the
48+
given library to produce requirements and the extras_require dict.
49+
50+
Args:
51+
requirements_file: str
52+
Path to the requirements file for this library
53+
library_name: str
54+
The name of the library being setup
55+
56+
Returns:
57+
requirements: List[str]
58+
The list of requirements to pass to setup()
59+
extras_require: Dict[str, List[str]]
60+
The extras_require dict to pass to setup()
61+
"""
62+
63+
# Import the library. This is used at build time, so it's safe to do so.
64+
importlib.import_module(library_name)
65+
66+
# Load all requirements from the requirements file
67+
with open(requirements_file, "r") as handle:
68+
requirements = {
69+
_standardize_package_name(req_split_expr.split(line, 1)[0]): line.strip()
70+
for line in handle.readlines()
71+
if line.strip() and not line.startswith("#")
72+
}
73+
this_pkg = sys.modules[__name__].__name__.split(".")[0]
74+
assert (
75+
this_pkg in requirements or this_pkg.replace("_", "-") in requirements
76+
), f"No requirement for {this_pkg} found"
77+
log.debug("Requirements: %s", requirements)
78+
79+
# Get the raw import sets for each tracked module
80+
import_sets = {
81+
tracked_module.split(".")[-1]: set(get_required_packages(tracked_module))
82+
for tracked_module in get_tracked_modules(library_name)
83+
}
84+
log.debug("Import sets: %s", import_sets)
85+
86+
# Determine the common requirements from the intersection of all import sets
87+
common_imports = None
88+
for import_set in import_sets.values():
89+
if common_imports is None:
90+
common_imports = import_set
91+
else:
92+
common_imports = common_imports.intersection(import_set)
93+
common_imports.add(_get_required_packages_for_imports([this_pkg])[0])
94+
log.debug("Common imports: %s", common_imports)
95+
96+
# Compute the sets of unique requirements for each tracked module
97+
extras_require_sets = {
98+
set_name: import_set - common_imports
99+
for set_name, import_set in import_sets.items()
100+
}
101+
log.debug("Extras require sets: %s", extras_require_sets)
102+
103+
# Add any listed requirements in that don't show up in any tracked module.
104+
# These requirements may be needed by an untracked portion of the library or
105+
# they may be runtime imports.
106+
all_tracked_requirements = reduce(
107+
lambda acc_set, req_set: acc_set.union(req_set),
108+
extras_require_sets.values(),
109+
common_imports,
110+
)
111+
missing_reqs = (
112+
set(_get_required_packages_for_imports(requirements.keys()))
113+
- all_tracked_requirements
114+
)
115+
log.debug(
116+
"Adding missing requirements %s to common_imports",
117+
sorted(list(missing_reqs)),
118+
)
119+
common_imports = common_imports.union(missing_reqs)
120+
121+
# Map all dependencies through those listed in requirements.txt
122+
standardized_requirements = {
123+
key.replace("-", "_"): val for key, val in requirements.items()
124+
}
125+
return _map_requirements(standardized_requirements, common_imports), {
126+
set_name: _map_requirements(standardized_requirements, import_set)
127+
for set_name, import_set in extras_require_sets.items()
128+
}

test/helpers.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,23 @@
1414
import import_tracker
1515

1616

17+
@pytest.fixture(autouse=True)
18+
def configure_logging():
19+
"""Fixture that configures logging from the env. It is auto-used, so if
20+
imported, it will automatically configure for each test.
21+
22+
NOTE: The import of alog is inside the function since alog is used as a
23+
sample package for lazy importing in some tests
24+
"""
25+
# First Party
26+
import alog
27+
28+
alog.configure(
29+
default_level=os.environ.get("LOG_LEVEL", "info"),
30+
filters=os.environ.get("LOG_FILTERS", ""),
31+
)
32+
33+
1734
@pytest.fixture(autouse=True)
1835
def reset_sys_modules():
1936
"""This fixture will reset the sys.modules dict to only the keys held before

test/test_import_tracker.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,13 @@ def test_get_required_packages_static_tracker(mode):
138138
import sample_lib
139139

140140
assert set(import_tracker.get_required_packages("sample_lib.submod2")) == {
141-
"alchemy-logging",
141+
"alchemy_logging",
142142
}
143143
assert set(
144144
import_tracker.get_required_packages("sample_lib.nested.submod3")
145145
) == {
146-
"alchemy-logging",
147-
"PyYAML",
146+
"alchemy_logging",
147+
"pyyaml",
148148
}
149149

150150

test/test_setup_tools.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Tests for setup tools
3+
"""
4+
5+
# Standard
6+
import os
7+
import tempfile
8+
9+
# Third Party
10+
import pytest
11+
12+
# Local
13+
from .helpers import configure_logging
14+
from import_tracker.setup_tools import parse_requirements
15+
16+
sample_lib_requirements = [
17+
"alchemy-logging>=1.0.3",
18+
"PyYaml >= 6.0",
19+
"conditional_deps",
20+
"import-tracker",
21+
]
22+
23+
24+
def test_parse_requirements_happy():
25+
"""Make sure that parse_requirements correctly parses requirements for a
26+
library with multiple tracked modules
27+
"""
28+
with tempfile.NamedTemporaryFile("w") as requirements_file:
29+
# Make a requirements file that looks normal
30+
requirements_file.write("\n".join(sample_lib_requirements))
31+
requirements_file.flush()
32+
33+
# Parse the reqs for "sample_lib"
34+
requirements, extras_require = parse_requirements(
35+
requirements_file.name,
36+
"sample_lib",
37+
)
38+
39+
# Make sure the right parsing happened
40+
assert requirements == ["import-tracker"]
41+
assert extras_require == {
42+
"submod3": sorted(["PyYaml >= 6.0", "alchemy-logging>=1.0.3"]),
43+
"submod1": sorted(["conditional_deps"]),
44+
"submod2": sorted(["alchemy-logging>=1.0.3"]),
45+
}
46+
47+
48+
def test_parse_requirements_add_untracked_reqs():
49+
"""Make sure that packages in the requirements.txt which don't show up in
50+
any of the tracked modules are added to the common requirements
51+
"""
52+
with tempfile.NamedTemporaryFile("w") as requirements_file:
53+
# Make a requirements file with an extra entry
54+
extra_req = "something-ElSe[extras]~=1.2.3"
55+
requirements_file.write("\n".join(sample_lib_requirements + [extra_req]))
56+
requirements_file.flush()
57+
58+
# Parse the reqs for "sample_lib"
59+
requirements, _ = parse_requirements(
60+
requirements_file.name,
61+
"sample_lib",
62+
)
63+
64+
# Make sure the extra requirement was added
65+
extra_req in requirements
66+
67+
68+
def test_parse_requirements_missing_import_tracker():
69+
"""Make sure that parse_requirements does require import_tracker (or
70+
import-tracker) in the requirements list
71+
"""
72+
with tempfile.NamedTemporaryFile("w") as requirements_file:
73+
# Make a requirements file that is missing import_tracker
74+
requirements_file.write(
75+
"\n".join(set(sample_lib_requirements) - {"import-tracker"})
76+
)
77+
requirements_file.flush()
78+
79+
# Make sure the assertion is tripped
80+
with pytest.raises(AssertionError):
81+
parse_requirements(requirements_file.name, "sample_lib")

0 commit comments

Comments
 (0)