Skip to content

Commit 4a9f181

Browse files
author
ajohns
committed
- reimplemented expand_requirement() to be more robust, allow any valid version range
- exposed all variant attribs in rex binding object - expose 'this' in @early bound package functions - strip functions, leading-__ variables from package.py - added tests for expand_requirement() - added Version.as_tuple(), and matching unit test
1 parent 8c5b818 commit 4a9f181

File tree

9 files changed

+314
-70
lines changed

9 files changed

+314
-70
lines changed

src/rez/developer_package.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,9 @@ def _get_preprocessed(self, data):
124124
from copy import deepcopy
125125

126126
with add_sys_paths(config.package_definition_build_python_paths):
127-
preprocess = getattr(self, "preprocess", None)
127+
preprocess_func = getattr(self, "preprocess", None)
128128

129-
if preprocess:
130-
preprocess_func = preprocess.func
129+
if preprocess_func:
131130
print_info("Applying preprocess from package.py")
132131
else:
133132
# load globally configured preprocess function
@@ -173,7 +172,7 @@ def _get_preprocessed(self, data):
173172
% (e.__class__.__name__, str(e)))
174173
return None
175174

176-
# if preprocess added functions, these need to be converted to
175+
# if preprocess added functions, these may need to be converted to
177176
# SourceCode instances
178177
preprocessed_data = process_python_objects(preprocessed_data)
179178

src/rez/package_py_utils.py

Lines changed: 110 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""
2-
This sourcefile is intended to only be imported in package.py files, in
3-
functions including:
2+
This sourcefile is intended to be imported in package.py files, in functions
3+
including:
44
55
- the special 'preprocess' function;
66
- early bound functions that use the @early decorator.
@@ -11,46 +11,133 @@
1111
from rez.exceptions import InvalidPackageError
1212

1313

14-
def expand_requirement(request):
15-
"""Expands a requirement string like 'python-2.*'
14+
def expand_requirement(request, paths=None):
15+
"""Expands a requirement string like 'python-2.*', 'foo-2.*+<*', etc.
1616
17-
Only trailing wildcards are supported; they will be replaced with the
18-
latest package version found within the range. If none are found, the
19-
wildcards will just be stripped.
17+
Wildcards are expanded to the latest version that matches. There is also a
18+
special wildcard '**' that will expand to the full version, but it cannot
19+
be used in combination with '*'.
2020
21-
Example:
21+
Wildcards MUST placehold a whole version token, not partial - while 'foo-2.*'
22+
is valid, 'foo-2.v*' is not.
23+
24+
Wildcards MUST appear at the end of version numbers - while 'foo-1.*.*' is
25+
valid, 'foo-1.*.0' is not.
26+
27+
It is possible that an expansion will result in an invalid request string
28+
(such as 'foo-2+<2'). The appropriate exception will be raised if this
29+
happens.
30+
31+
Examples:
2232
2333
>>> print expand_requirement('python-2.*')
2434
python-2.7
35+
>>> print expand_requirement('python==2.**')
36+
python==2.7.12
37+
>>> print expand_requirement('python<**')
38+
python<3.0.5
2539
2640
Args:
2741
request (str): Request to expand, eg 'python-2.*'
42+
paths (list of str, optional): paths to search for package families,
43+
defaults to `config.packages_path`.
2844
2945
Returns:
3046
str: Expanded request string.
3147
"""
3248
if '*' not in request:
3349
return request
3450

35-
from rez.vendor.version.requirement import VersionedObject, Requirement
51+
from rez.vendor.version.version import VersionRange
52+
from rez.vendor.version.requirement import Requirement
3653
from rez.packages_ import get_latest_package
54+
from uuid import uuid4
3755

38-
txt = request.replace('*', '_')
39-
obj = VersionedObject(txt)
40-
rank = len(obj.version)
41-
56+
wildcard_map = {}
57+
expanded_versions = {}
4258
request_ = request
43-
while request_.endswith('*'):
44-
request_ = request_[:-2] # strip sep + *
45-
46-
req = Requirement(request_)
47-
package = get_latest_package(name=req.name, range_=req.range_)
48-
49-
if package is None:
50-
return request_
5159

52-
obj.version_ = package.version.trim(rank)
53-
return str(obj)
60+
# replace wildcards with valid version tokens that can be replaced again
61+
# afterwards. This produces a horrendous, but both valid and temporary,
62+
# version string.
63+
#
64+
while "**" in request_:
65+
uid = "_%s_" % uuid4().hex
66+
request_ = request_.replace("**", uid, 1)
67+
wildcard_map[uid] = "**"
68+
69+
while '*' in request_:
70+
uid = "_%s_" % uuid4().hex
71+
request_ = request_.replace('*', uid, 1)
72+
wildcard_map[uid] = '*'
73+
74+
# create the requirement, then expand wildcards
75+
#
76+
req = Requirement(request_, invalid_bound_error=False)
77+
78+
def expand_version(version):
79+
rank = len(version)
80+
wildcard_found = False
81+
82+
while version and str(version[-1]) in wildcard_map:
83+
token = wildcard_map[str(version[-1])]
84+
version = version.trim(len(version) - 1)
85+
86+
if token == "**":
87+
if wildcard_found: # catches bad syntax '**.*'
88+
return None
89+
else:
90+
wildcard_found = True
91+
rank = 0
92+
break
93+
94+
wildcard_found = True
95+
96+
if not wildcard_found:
97+
return None
98+
99+
range_ = VersionRange(str(version))
100+
package = get_latest_package(name=req.name, range_=range_, paths=paths)
101+
102+
if package is None:
103+
return version
104+
105+
if rank:
106+
return package.version.trim(rank)
107+
else:
108+
return package.version
109+
110+
def visit_version(version):
111+
# requirements like 'foo-1' are actually represented internally as
112+
# 'foo-1+<1_' - '1_' is the next possible version after '1'. So we have
113+
# to detect this case and remap the uid-ified wildcard back here too.
114+
#
115+
for v, expanded_v in expanded_versions.iteritems():
116+
if version == v.next():
117+
return expanded_v.next()
118+
119+
version_ = expand_version(version)
120+
if version_ is None:
121+
return None
122+
123+
expanded_versions[version] = version_
124+
return version_
125+
126+
if req.range_ is not None:
127+
req.range_.visit_versions(visit_version)
128+
129+
result = str(req)
130+
131+
# do some cleanup so that long uids aren't left in invalid wildcarded strings
132+
for uid, token in wildcard_map.iteritems():
133+
result = result.replace(uid, token)
134+
135+
# cast back to a Requirement again, then back to a string. This will catch
136+
# bad verison ranges, but will also put OR'd version ranges into the correct
137+
# order
138+
expanded_req = Requirement(result)
139+
140+
return str(expanded_req)
54141

55142

56143
def expand_requires(*requests):

src/rez/rex_bindings.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,22 @@ def __iter__(self):
103103
class VariantBinding(Binding):
104104
"""Binds a packages.Variant object."""
105105
def __init__(self, variant):
106-
doc = dict(
107-
name=variant.name,
108-
version=VersionBinding(variant.version),
109-
base=variant.base,
110-
root=variant.root)
106+
doc = dict(version=VersionBinding(variant.version))
111107
super(VariantBinding, self).__init__(doc)
112108
self.__variant = variant
113109

110+
# hacky, but we'll be deprecating all these bindings..
111+
def __getattr__(self, attr):
112+
try:
113+
return super(VariantBinding, self).__getattr__(attr)
114+
except:
115+
missing = object()
116+
value = getattr(self.__variant, attr, missing)
117+
if value is missing:
118+
raise
119+
120+
return value
121+
114122
def _attr_error(self, attr):
115123
raise AttributeError("package %s has no attribute '%s'"
116124
% (str(self), attr))

src/rez/serialise.py

Lines changed: 85 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -170,40 +170,84 @@ def load_py(stream, filepath=None):
170170
return result
171171

172172

173+
class EarlyThis(object):
174+
"""The 'this' object for @early bound functions."""
175+
def __init__(self, data):
176+
self._data = data
177+
178+
def __getattr__(self, attr):
179+
missing = object()
180+
value = self._data.get(attr, missing)
181+
if value is missing:
182+
raise AttributeError("No such package attribute '%s'" % attr)
183+
184+
if isfunction(value) and (hasattr(value, "_early") or hasattr(value, "_late")):
185+
raise ValueError(
186+
"An early binding function cannot refer to another early or "
187+
"late binding function: '%s'" % attr)
188+
189+
return value
190+
191+
173192
def process_python_objects(data, filepath=None):
193+
"""Replace certain values in the given package data dict.
174194
175-
_remove = object()
195+
Does things like:
196+
* evaluates @early decorated functions, and replaces with return value;
197+
* converts functions into `SourceCode` instances so they can be serialized
198+
out to installed packages, and evaluated later;
199+
* strips some values (modules, __-leading variables) that are never to be
200+
part of installed packages.
176201
202+
Returns:
203+
dict: Updated dict.
204+
"""
177205
def _process(value):
178206
if isinstance(value, dict):
179207
for k, v in value.items():
180-
new_value = _process(v)
181-
182-
if new_value is _remove:
183-
del value[k]
184-
else:
185-
value[k] = new_value
208+
value[k] = _process(v)
186209

187210
return value
188211
elif isfunction(value):
189-
if hasattr(value, "_early"):
212+
func = value
213+
214+
if hasattr(func, "_early"):
190215
# run the function now, and replace with return value
191-
with add_sys_paths(config.package_definition_build_python_paths):
192-
func = value
216+
#
217+
218+
# make a copy of the func with its own globals, and add 'this'
219+
import types
220+
fn = types.FunctionType(func.func_code,
221+
func.func_globals.copy(),
222+
name=func.func_name,
223+
argdefs=func.func_defaults,
224+
closure=func.func_closure)
193225

226+
this = EarlyThis(data)
227+
fn.func_globals.update({"this": this})
228+
229+
with add_sys_paths(config.package_definition_build_python_paths):
230+
# this 'data' arg support isn't needed anymore, but I'm
231+
# supporting it til I know nobody is using it...
232+
#
194233
spec = getargspec(func)
195234
args = spec.args or []
196235
if len(args) not in (0, 1):
197236
raise ResourceError("@early decorated function must "
198237
"take zero or one args only")
199238
if args:
200-
value_ = func(data)
239+
value_ = fn(data)
201240
else:
202-
value_ = func()
241+
value_ = fn()
203242

204243
# process again in case this is a function returning a function
205244
return _process(value_)
206-
else:
245+
246+
elif hasattr(func, "_late"):
247+
return SourceCode(func=func, filepath=filepath,
248+
eval_as_function=True)
249+
250+
elif func.__name__ in package_rex_keys:
207251
# if a rex function, the code has to be eval'd NOT as a function,
208252
# otherwise the globals dict doesn't get updated with any vars
209253
# defined in the code, and that means rex code like this:
@@ -214,20 +258,37 @@ def _process(value):
214258
# ..won't work. It was never intentional that the above work, but
215259
# it does, so now we have to keep it so.
216260
#
217-
as_function = (value.__name__ not in package_rex_keys)
218-
219-
return SourceCode(func=value, filepath=filepath,
220-
eval_as_function=as_function)
221-
elif ismodule(value):
222-
# modules cannot be installed as package attributes. They are present
223-
# in developer packages sometimes though - it's fine for a package
224-
# attribute to use an imported module at build time.
225-
#
226-
return _remove
261+
return SourceCode(func=func, filepath=filepath,
262+
eval_as_function=False)
263+
264+
else:
265+
# a normal function. Leave unchanged, it will be stripped after
266+
return func
227267
else:
228268
return value
229269

230-
return _process(data)
270+
def _trim(value):
271+
if isinstance(value, dict):
272+
for k, v in value.items():
273+
if isfunction(v):
274+
if v.__name__ == "preprocess":
275+
# preprocess is a special case. It has to stay intact
276+
# until the `DeveloperPackage` has a chance to apply it;
277+
# after which it gets removed from the package attributes.
278+
#
279+
pass
280+
else:
281+
del value[k]
282+
elif ismodule(v) or k.startswith("__"):
283+
del value[k]
284+
else:
285+
value[k] = _trim(v)
286+
287+
return value
288+
289+
data = _process(data)
290+
data = _trim(data)
291+
return data
231292

232293

233294
def load_yaml(stream, **kwargs):

0 commit comments

Comments
 (0)