aboutsummaryrefslogtreecommitdiff
path: root/extras/pytest.py
blob: 7dd5a1a087a31e5d35395f9d3d0977c3901a0a9b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
#! /usr/bin/env python
# encoding: utf-8
# Calle Rosenquist, 2016-2018 (xbreak)

"""
Provides Python unit test support using :py:class:`waflib.Tools.waf_unit_test.utest`
task via the **pytest** feature.

To use pytest the following is needed:

1. Load `pytest` and the dependency `waf_unit_test` tools.
2. Create a task generator with feature `pytest` (not `test`) and customize behaviour with
   the following attributes:

   - `pytest_source`: Test input files.
   - `ut_str`: Test runner command, e.g. ``${PYTHON} -B -m unittest discover`` or
               if nose is used: ``${NOSETESTS} --no-byte-compile ${SRC}``.
   - `ut_shell`: Determines if ``ut_str`` is executed in a shell. Default: False.
   - `ut_cwd`: Working directory for test runner. Defaults to directory of
               first ``pytest_source`` file.

   Additionally the following `pytest` specific attributes are used in dependent taskgens:

   - `pytest_path`: Node or string list of additional Python paths.
   - `pytest_libpath`: Node or string list of additional library paths.

The `use` dependencies are used for both update calculation and to populate
the following environment variables for the `pytest` test runner:

1. `PYTHONPATH` (`sys.path`) of any dependent taskgen that has the feature `py`:

   - `install_from` attribute is used to determine where the root of the Python sources
      are located. If `install_from` is not specified the default is to use the taskgen path
      as the root.

   - `pytest_path` attribute is used to manually specify additional Python paths.

2. Dynamic linker search path variable (e.g. `LD_LIBRARY_PATH`) of any dependent taskgen with
   non-static link_task.

   - `pytest_libpath` attribute is used to manually specify additional linker paths.

Note: `pytest` cannot automatically determine the correct `PYTHONPATH` for `pyext` taskgens
      because the extension might be part of a Python package or used standalone:

      - When used as part of another `py` package, the `PYTHONPATH` is provided by
      that taskgen so no additional action is required.

      - When used as a standalone module, the user needs to specify the `PYTHONPATH` explicitly
      via the `pytest_path` attribute on the `pyext` taskgen.

      For details c.f. the pytest playground examples.


For example::

    # A standalone Python C extension that demonstrates unit test environment population
    # of PYTHONPATH and LD_LIBRARY_PATH/PATH/DYLD_LIBRARY_PATH.
    #
    # Note: `pytest_path` is provided here because pytest cannot automatically determine
    # if the extension is part of another Python package or is used standalone.
    bld(name         = 'foo_ext',
        features     = 'c cshlib pyext',
        source       = 'src/foo_ext.c',
        target       = 'foo_ext',
        pytest_path  = [ bld.path.get_bld() ])

    # Python package under test that also depend on the Python module `foo_ext`
    #
    # Note: `install_from` is added automatically to `PYTHONPATH`.
    bld(name         = 'foo',
        features     = 'py',
        use          = 'foo_ext',
        source       = bld.path.ant_glob('src/foo/*.py'),
        install_from = 'src')

    # Unit test example using the built in module unittest and let that discover
    # any test cases.
    bld(name          = 'foo_test',
        features      = 'pytest',
        use           = 'foo',
        pytest_source = bld.path.ant_glob('test/*.py'),
        ut_str        = '${PYTHON} -B -m unittest discover')

"""

import os
from waflib import Task, TaskGen, Errors, Utils, Logs
from waflib.Tools import ccroot

def _process_use_rec(self, name):
	"""
	Recursively process ``use`` for task generator with name ``name``..
	Used by pytest_process_use.
	"""
	if name in self.pytest_use_not or name in self.pytest_use_seen:
		return
	try:
		tg = self.bld.get_tgen_by_name(name)
	except Errors.WafError:
		self.pytest_use_not.add(name)
		return

	self.pytest_use_seen.append(name)
	tg.post()

	for n in self.to_list(getattr(tg, 'use', [])):
		_process_use_rec(self, n)


@TaskGen.feature('pytest')
@TaskGen.after_method('process_source', 'apply_link')
def pytest_process_use(self):
	"""
	Process the ``use`` attribute which contains a list of task generator names and store
	paths that later is used to populate the unit test runtime environment.
	"""
	self.pytest_use_not = set()
	self.pytest_use_seen = []
	self.pytest_paths = [] # strings or Nodes
	self.pytest_libpaths = [] # strings or Nodes
	self.pytest_dep_nodes = []

	names = self.to_list(getattr(self, 'use', []))
	for name in names:
		_process_use_rec(self, name)
	
	def extend_unique(lst, varlst):
		ext = []
		for x in varlst:
			if x not in lst:
				ext.append(x)
		lst.extend(ext)

	# Collect type specific info needed to construct a valid runtime environment
	# for the test.
	for name in self.pytest_use_seen:
		tg = self.bld.get_tgen_by_name(name)

		extend_unique(self.pytest_paths, Utils.to_list(getattr(tg, 'pytest_path', [])))
		extend_unique(self.pytest_libpaths, Utils.to_list(getattr(tg, 'pytest_libpath', [])))

		if 'py' in tg.features:
			# Python dependencies are added to PYTHONPATH
			pypath = getattr(tg, 'install_from', tg.path)

			if 'buildcopy' in tg.features:
				# Since buildcopy is used we assume that PYTHONPATH in build should be used,
				# not source
				extend_unique(self.pytest_paths, [pypath.get_bld().abspath()])

				# Add buildcopy output nodes to dependencies
				extend_unique(self.pytest_dep_nodes, [o for task in getattr(tg, 'tasks', []) \
														for o in getattr(task, 'outputs', [])])
			else:
				# If buildcopy is not used, depend on sources instead
				extend_unique(self.pytest_dep_nodes, tg.source)
				extend_unique(self.pytest_paths, [pypath.abspath()])

		if getattr(tg, 'link_task', None):
			# For tasks with a link_task (C, C++, D et.c.) include their library paths:
			if not isinstance(tg.link_task, ccroot.stlink_task):
				extend_unique(self.pytest_dep_nodes, tg.link_task.outputs)
				extend_unique(self.pytest_libpaths, tg.link_task.env.LIBPATH)

				if 'pyext' in tg.features:
					# If the taskgen is extending Python we also want to add the interpreter libpath.
					extend_unique(self.pytest_libpaths, tg.link_task.env.LIBPATH_PYEXT)
				else:
					# Only add to libpath if the link task is not a Python extension
					extend_unique(self.pytest_libpaths, [tg.link_task.outputs[0].parent.abspath()])


@TaskGen.feature('pytest')
@TaskGen.after_method('pytest_process_use')
def make_pytest(self):
	"""
	Creates a ``utest`` task with a populated environment for Python if not specified in ``ut_env``:

	- Paths in `pytest_paths` attribute are used to populate PYTHONPATH
	- Paths in `pytest_libpaths` attribute are used to populate the system library path (e.g. LD_LIBRARY_PATH)
	"""
	nodes = self.to_nodes(self.pytest_source)
	tsk = self.create_task('utest', nodes)
	
	tsk.dep_nodes.extend(self.pytest_dep_nodes)
	if getattr(self, 'ut_str', None):
		self.ut_run, lst = Task.compile_fun(self.ut_str, shell=getattr(self, 'ut_shell', False))
		tsk.vars = lst + tsk.vars

	if getattr(self, 'ut_cwd', None):
		if isinstance(self.ut_cwd, str):
			# we want a Node instance
			if os.path.isabs(self.ut_cwd):
				self.ut_cwd = self.bld.root.make_node(self.ut_cwd)
			else:
				self.ut_cwd = self.path.make_node(self.ut_cwd)
	else:
		if tsk.inputs:
			self.ut_cwd = tsk.inputs[0].parent
		else:
			raise Errors.WafError("no valid input files for pytest task, check pytest_source value")

	if not self.ut_cwd.exists():
		self.ut_cwd.mkdir()

	if not hasattr(self, 'ut_env'):
		self.ut_env = dict(os.environ)
		def add_paths(var, lst):
			# Add list of paths to a variable, lst can contain strings or nodes
			lst = [ str(n) for n in lst ]
			Logs.debug("ut: %s: Adding paths %s=%s", self, var, lst)
			self.ut_env[var] = os.pathsep.join(lst) + os.pathsep + self.ut_env.get(var, '')

		# Prepend dependency paths to PYTHONPATH and LD_LIBRARY_PATH
		add_paths('PYTHONPATH', self.pytest_paths)

		if Utils.is_win32:
			add_paths('PATH', self.pytest_libpaths)
		elif Utils.unversioned_sys_platform() == 'darwin':
			add_paths('DYLD_LIBRARY_PATH', self.pytest_libpaths)
			add_paths('LD_LIBRARY_PATH', self.pytest_libpaths)
		else:
			add_paths('LD_LIBRARY_PATH', self.pytest_libpaths)