Skip to content

Commit 8ee173c

Browse files
committed
Follow package dependency graph from changed packages.
This may expand the list, for example if `core` is among the changed packages then all downstream packages need to be tested.
1 parent 8a917aa commit 8ee173c

File tree

2 files changed

+127
-2
lines changed

2 files changed

+127
-2
lines changed

scripts/run_unit_tests.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import sys
2828

2929
from script_utils import check_output
30+
from script_utils import follow_dependencies
3031
from script_utils import get_changed_packages
3132
from script_utils import in_travis
3233
from script_utils import in_travis_pr
@@ -126,6 +127,12 @@ def get_test_packages():
126127
any filtering)
127128
* Just use all packages
128129
130+
An additional check is done for the cases when a diff is computed (i.e.
131+
using local remote and local branch environment variables, and on Travis).
132+
Once the filtered list of **changed** packages is found, the package
133+
dependency graph is used to add any additional packages which depend on
134+
the changed packages.
135+
129136
:rtype: list
130137
:returns: A list of all package directories where tests
131138
need be run.
@@ -139,9 +146,12 @@ def get_test_packages():
139146
verify_packages(args.packages, all_packages)
140147
return sorted(args.packages)
141148
elif local_diff is not None:
142-
return get_changed_packages('HEAD', local_diff, all_packages)
149+
changed_packages = get_changed_packages(
150+
'HEAD', local_diff, all_packages)
151+
return follow_dependencies(changed_packages, all_packages)
143152
elif in_travis():
144-
return get_travis_directories(all_packages)
153+
changed_packages = get_travis_directories(all_packages)
154+
return follow_dependencies(changed_packages, all_packages)
145155
else:
146156
return all_packages
147157

scripts/script_utils.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import print_function
1818

19+
import ast
1920
import os
2021
import subprocess
2122

@@ -27,6 +28,9 @@
2728
IN_TRAVIS_ENV = 'TRAVIS'
2829
TRAVIS_PR_ENV = 'TRAVIS_PULL_REQUEST'
2930
TRAVIS_BRANCH_ENV = 'TRAVIS_BRANCH'
31+
INST_REQS_KWARG = 'install_requires'
32+
REQ_VAR = 'REQUIREMENTS'
33+
PACKAGE_PREFIX = 'google-cloud-'
3034

3135

3236
def in_travis():
@@ -228,3 +232,114 @@ def get_affected_files(allow_limited=True):
228232
result = subprocess.check_output(['git', 'ls-files'])
229233

230234
return result.rstrip('\n').split('\n'), diff_base
235+
236+
237+
def get_required_packages(file_contents):
238+
"""Get required packages from a ``setup.py`` file.
239+
240+
Makes the following assumptions:
241+
242+
* ``install_requires=REQUIREMENTS`` occurs in the call to
243+
``setup()`` in the ``file_contents``.
244+
* The text ``install_requires`` occurs nowhere else in the file.
245+
* The text ``REQUIREMENTS`` only appears when being passed to
246+
``setup()`` (as above) and when being defined.
247+
* The ``REQUIREMENTS`` variable is a list and the text from the
248+
``setup.py`` file containing that list can be parsed using
249+
``ast.literal_eval()``.
250+
251+
:type file_contents: str
252+
:param file_contents: The contents of a ``setup.py`` file.
253+
254+
:rtype: list
255+
:returns: The list of required packages.
256+
:raises: :class:`~exceptions.ValueError` if the file is in an
257+
unexpected format.
258+
"""
259+
# Make sure the only ``install_requires`` happens in the
260+
# call to setup()
261+
if file_contents.count(INST_REQS_KWARG) != 1:
262+
raise ValueError('Expected only one use of keyword',
263+
INST_REQS_KWARG, file_contents)
264+
# Make sure the only usage of ``install_requires`` is to set
265+
# install_requires=REQUIREMENTS.
266+
keyword_stmt = INST_REQS_KWARG + '=' + REQ_VAR
267+
if file_contents.count(keyword_stmt) != 1:
268+
raise ValueError('Expected keyword to be set with variable',
269+
INST_REQS_KWARG, REQ_VAR, file_contents)
270+
# Split file on ``REQUIREMENTS`` variable while asserting that
271+
# it only appear twice.
272+
_, reqs_section, _ = file_contents.split(REQ_VAR)
273+
# Find ``REQUIREMENTS`` list variable defined in ``reqs_section``.
274+
reqs_begin = reqs_section.index('[')
275+
reqs_end = reqs_section.index(']') + 1
276+
277+
# Convert the text to an actual list, but make sure no
278+
# locals or globals can be used.
279+
reqs_list_text = reqs_section[reqs_begin:reqs_end]
280+
# We use literal_eval() because it limits to evaluating
281+
# strings that only consist of a few Python literals: strings,
282+
# numbers, tuples, lists, dicts, booleans, and None.
283+
requirements = ast.literal_eval(reqs_list_text)
284+
285+
# Take the list of requirements and strip off the package name
286+
# from each requirement.
287+
result = []
288+
for required in requirements:
289+
parts = required.split()
290+
result.append(parts[0])
291+
return result
292+
293+
294+
def get_dependency_graph(package_list):
295+
"""Get a directed graph of package dependencies.
296+
297+
:type package_list: list
298+
:param package_list: The list of **all** valid packages.
299+
300+
:rtype: dict
301+
:returns: A dictionary where keys are packages and values are
302+
the set of packages that depend on the key.
303+
"""
304+
result = {package: set() for package in package_list}
305+
for package in package_list:
306+
setup_file = os.path.join(PROJECT_ROOT, package,
307+
'setup.py')
308+
with open(setup_file, 'r') as file_obj:
309+
file_contents = file_obj.read()
310+
311+
requirements = get_required_packages(file_contents)
312+
for requirement in requirements:
313+
if not requirement.startswith(PACKAGE_PREFIX):
314+
continue
315+
_, req_package = requirement.split(PACKAGE_PREFIX)
316+
req_package = req_package.replace('-', '_')
317+
result[req_package].add(package)
318+
319+
return result
320+
321+
322+
def follow_dependencies(subset, package_list):
323+
"""Get a directed graph of package dependencies.
324+
325+
:type subset: list
326+
:param subset: List of a subset of package names.
327+
328+
:type package_list: list
329+
:param package_list: The list of **all** valid packages.
330+
331+
:rtype: list
332+
:returns: An expanded list of packages containing everything
333+
in ``subset`` and any packages that depend on those.
334+
"""
335+
dependency_graph = get_dependency_graph(package_list)
336+
337+
curr_pkgs = None
338+
updated_pkgs = set(subset)
339+
while curr_pkgs != updated_pkgs:
340+
curr_pkgs = updated_pkgs
341+
updated_pkgs = set(curr_pkgs)
342+
for package in curr_pkgs:
343+
updated_pkgs.update(dependency_graph[package])
344+
345+
return sorted(curr_pkgs)

0 commit comments

Comments
 (0)