-
Notifications
You must be signed in to change notification settings - Fork 981
/
graph_lock.py
702 lines (613 loc) · 28.2 KB
/
graph_lock.py
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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
import json
import os
from collections import OrderedDict
from conans import DEFAULT_REVISION_V1
from conans.client.graph.graph import RECIPE_VIRTUAL, RECIPE_CONSUMER
from conans.client.graph.python_requires import PyRequires
from conans.client.graph.range_resolver import satisfying
from conans.client.profile_loader import _load_profile
from conans.errors import ConanException
from conans.model.info import PACKAGE_ID_UNKNOWN
from conans.model.options import OptionsValues
from conans.model.ref import ConanFileReference
from conans.util.files import load, save
LOCKFILE = "conan.lock"
LOCKFILE_VERSION = "0.4"
class GraphLockFile(object):
def __init__(self, profile_host, profile_build, graph_lock):
self._profile_host = profile_host
self._profile_build = profile_build
self._graph_lock = graph_lock
@property
def graph_lock(self):
return self._graph_lock
@property
def profile_host(self):
return self._profile_host
@property
def profile_build(self):
return self._profile_build
@staticmethod
def load(path, revisions_enabled):
if not path:
raise IOError("Invalid path")
if not os.path.isfile(path):
raise ConanException("Missing lockfile in: %s" % path)
content = load(path)
try:
return GraphLockFile._loads(content, revisions_enabled)
except Exception as e:
raise ConanException("Error parsing lockfile '{}': {}".format(path, e))
def save(self, path):
serialized_graph_str = self._dumps(path)
save(path, serialized_graph_str)
@staticmethod
def _loads(text, revisions_enabled):
graph_json = json.loads(text)
version = graph_json.get("version")
if version:
if version != LOCKFILE_VERSION:
raise ConanException("This lockfile was created with an incompatible "
"version. Please regenerate the lockfile")
# Do something with it, migrate, raise...
profile_host = graph_json.get("profile_host", None)
profile_build = graph_json.get("profile_build", None)
# FIXME: Reading private very ugly
if profile_host:
profile_host, _ = _load_profile(profile_host, None, None)
if profile_build:
profile_build, _ = _load_profile(profile_build, None, None)
graph_lock = GraphLock.deserialize(graph_json["graph_lock"], revisions_enabled)
graph_lock_file = GraphLockFile(profile_host, profile_build, graph_lock)
return graph_lock_file
def _dumps(self, path):
# Make the lockfile more reproducible by using a relative path in the node.path
# At the moment the node.path value is not really used, only its existence
path = os.path.dirname(path)
serial_lock = self._graph_lock.serialize()
for node in serial_lock["nodes"].values():
p = node.get("path")
if p:
try: # In Windows with different drives D: C: this fails
node["path"] = os.path.relpath(p, path)
except ValueError:
pass
result = {"graph_lock": serial_lock,
"version": LOCKFILE_VERSION}
if self._profile_host:
result["profile_host"] = self._profile_host.dumps()
if self._profile_build:
result["profile_build"] = self._profile_build.dumps()
return json.dumps(result, indent=True)
def only_recipes(self):
self._graph_lock.only_recipes()
self._profile_host = None
self._profile_build = None
class GraphLockNode(object):
def __init__(self, ref, package_id, prev, python_requires, options, requires, build_requires,
path, revisions_enabled, context, modified=None):
self._ref = ref if ref and ref.name else None # includes rrev
self._package_id = package_id
self._context = context
self._prev = prev
self._requires = requires
self._build_requires = build_requires
if revisions_enabled:
self._python_requires = python_requires
else:
self._python_requires = [r.copy_clear_rev() for r in python_requires or []]
self._options = options
self._revisions_enabled = revisions_enabled
self._relaxed = False
self._modified = modified # Exclusively now for "conan_build_info" command
self._path = path
if not revisions_enabled:
if ref:
self._ref = ref.copy_clear_rev()
if prev:
self._prev = DEFAULT_REVISION_V1
@property
def context(self):
return self._context
@property
def requires(self):
return self._requires
@property
def modified(self):
return self._modified
@property
def build_requires(self):
return self._build_requires
def relax(self):
self._relaxed = True
def clean_modified(self):
self._modified = None
@property
def path(self):
return self._path
@property
def ref(self):
return self._ref
@property
def python_requires(self):
return self._python_requires
@ref.setter
def ref(self, value):
# only used at export time, to assign rrev
if not self._revisions_enabled:
value = value.copy_clear_rev()
if self._ref:
if (self._ref.copy_clear_rev() != value.copy_clear_rev() or
(self._ref.revision and self._ref.revision != value.revision) or
self._prev):
raise ConanException("Attempt to modify locked %s to %s"
% (repr(self._ref), repr(value)))
self._ref = value
# Just in case
self._path = None
@property
def package_id(self):
return self._package_id
@package_id.setter
def package_id(self, value):
if (self._package_id is not None and self._package_id != PACKAGE_ID_UNKNOWN and
self._package_id != value):
raise ConanException("Attempt to change package_id of locked '%s'" % repr(self._ref))
if value != self._package_id: # When the package_id is being assigned, prev becomes invalid
self._prev = None
self._package_id = value
@property
def prev(self):
return self._prev
@prev.setter
def prev(self, value):
if not self._revisions_enabled and value is not None:
value = DEFAULT_REVISION_V1
if self._prev is not None:
raise ConanException("Trying to modify locked package {}".format(repr(self._ref)))
if value is not None:
self._modified = True # Only for conan_build_info
self._prev = value
def unlock_prev(self):
""" for creating a new lockfile from an existing one, when specifying --build, it
should make prev=None in order to unlock it and allow building again"""
if self._prev is None:
return # Already unlocked
if not self._relaxed:
raise ConanException("Cannot build '%s' because it is already locked in the "
"input lockfile" % repr(self._ref))
self._prev = None
def complete_base_node(self, package_id, prev):
# completing a node from a base lockfile shouldn't mark the node as modified
self.package_id = package_id
self.prev = prev
self._modified = None
@property
def options(self):
return self._options
def only_recipe(self):
self._package_id = None
self._prev = None
self._options = None
self._modified = None
@staticmethod
def deserialize(data, revisions_enabled):
""" constructs a GraphLockNode from a json like dict
"""
json_ref = data.get("ref")
ref = ConanFileReference.loads(json_ref) if json_ref else None
package_id = data.get("package_id")
prev = data.get("prev")
python_requires = data.get("python_requires")
if python_requires:
python_requires = [ConanFileReference.loads(py_req, validate=False)
for py_req in python_requires]
options = data.get("options")
options = OptionsValues.loads(options) if options else None
modified = data.get("modified")
context = data.get("context")
requires = data.get("requires", [])
build_requires = data.get("build_requires", [])
path = data.get("path")
return GraphLockNode(ref, package_id, prev, python_requires, options, requires,
build_requires, path, revisions_enabled, context, modified)
def serialize(self):
""" returns the object serialized as a dict of plain python types
that can be converted to json
"""
result = {}
if self._ref:
result["ref"] = repr(self._ref)
if self._options:
result["options"] = self._options.dumps()
if self._package_id:
result["package_id"] = self._package_id
if self._prev:
result["prev"] = self._prev
if self.python_requires:
result["python_requires"] = [repr(r) for r in self.python_requires]
if self._modified:
result["modified"] = self._modified
if self._requires:
result["requires"] = self._requires
if self._build_requires:
result["build_requires"] = self._build_requires
if self._path:
result["path"] = self._path
if self._context:
result["context"] = self._context
return result
class GraphLock(object):
def __init__(self, deps_graph, revisions_enabled):
self._nodes = {} # {id: GraphLockNode}
self._revisions_enabled = revisions_enabled
self._relaxed = False # If True, the lock can be expanded with new Nodes
if deps_graph is None:
return
for graph_node in deps_graph.nodes:
if graph_node.recipe == RECIPE_VIRTUAL:
continue
# Creating a GraphLockNode from the existing DepsGraph node
requires = []
build_requires = []
for edge in graph_node.dependencies:
if edge.build_require:
build_requires.append(edge.dst.id)
else:
requires.append(edge.dst.id)
# It is necessary to lock the transitive python-requires too, for this node
python_reqs = None
reqs = getattr(graph_node.conanfile, "python_requires", {})
if isinstance(reqs, dict): # Old python_requires
python_reqs = {}
while reqs:
python_reqs.update(reqs)
partial = {}
for req in reqs.values():
partial.update(getattr(req.conanfile, "python_requires", {}))
reqs = partial
python_reqs = [r.ref for _, r in python_reqs.items()]
elif isinstance(reqs, PyRequires):
python_reqs = graph_node.conanfile.python_requires.all_refs()
ref = graph_node.ref if graph_node.ref and graph_node.ref.name else None
package_id = graph_node.package_id if ref and ref.revision else None
prev = graph_node.prev if ref and ref.revision else None
# Make sure to inherit the modified flag in case it is a partial lock
modified = graph_node.graph_lock_node.modified if graph_node.graph_lock_node else None
lock_node = GraphLockNode(ref, package_id, prev, python_reqs,
graph_node.conanfile.options.values, requires, build_requires,
graph_node.path, self._revisions_enabled, graph_node.context,
modified=modified)
graph_node.graph_lock_node = lock_node
self._nodes[graph_node.id] = lock_node
@property
def nodes(self):
return self._nodes
def relax(self):
""" A lockfile is strict in its topology. It cannot add new nodes, have non-locked
requirements or have unused locked requirements. This method is called only:
- With "conan lock create --lockfile=existing --lockfile-out=new
- for the "test_package" functionality, as test_package/conanfile.py can have requirements
and those will never exist in the lockfile
"""
self._relaxed = True
for n in self._nodes.values():
n.relax()
@property
def relaxed(self):
return self._relaxed
def clean_modified(self):
for n in self._nodes.values():
n.clean_modified()
def build_order(self):
""" This build order uses empty PREVs to decide which packages need to be built
:return: An ordered list of lists, each inner element is a tuple with the node ID and the
reference (as string), possibly including revision, of the node
"""
# First do a topological order by levels, the ids of the nodes are stored
levels = []
opened = list(self._nodes.keys())
while opened:
current_level = []
for o in opened:
node = self._nodes[o]
requires = node.requires
if node.python_requires:
requires += node.python_requires
if node.build_requires:
requires += node.build_requires
if not any(n in opened for n in requires):
current_level.append(o)
current_level.sort()
levels.append(current_level)
# now initialize new level
opened = set(opened).difference(current_level)
# Now compute the list of list with prev=None, and prepare them with the right
# references to be used in cmd line
result = []
total_prefs = set() # to remove duplicates, same pref shouldn't build twice
for level in levels:
new_level = []
for id_ in level:
locked_node = self._nodes[id_]
if locked_node.prev is None and locked_node.package_id is not None:
# Manipulate the ref so it can be used directly in install command
ref = repr(locked_node.ref)
if not self._revisions_enabled:
if "@" not in ref:
ref += "@"
else:
if "@" not in ref:
ref = ref.replace("#", "@#")
if (ref, locked_node.package_id, locked_node.context) not in total_prefs:
new_level.append((ref, locked_node.package_id, locked_node.context, id_))
total_prefs.add((ref, locked_node.package_id, locked_node.context))
if new_level:
result.append(new_level)
return result
def complete_matching_prevs(self):
""" when a build_require that has the same ref and package_id is built, only one node
gets its PREV updated. This method fills the repeated nodes missing PREV to the same one.
The build-order only returned 1 node (matching ref:package_id).
"""
groups = {}
for node in self._nodes.values():
groups.setdefault((node.ref, node.package_id), []).append(node)
for nodes in groups.values():
if len(nodes) > 1:
prevs = set(node.prev for node in nodes if node.prev)
if prevs:
assert len(prevs) == 1, "packages in lockfile with different PREVs"
prev = prevs.pop()
for node in nodes:
if node.prev is None:
node.prev = prev
def only_recipes(self):
""" call this method to remove the packages/binaries information from the lockfile, and
keep only the reference version and RREV. A lockfile with this stripped information can
be used for creating new lockfiles based on it
"""
for node in self._nodes.values():
node.only_recipe()
@property
def initial_counter(self):
""" When a new, relaxed graph is being created based on this lockfile, it can add new
nodes. The IDs of those nodes need a base ID, to not collide with the existing ones
:return: the maximum ID of this lockfile, as integer
"""
# IDs are string, we need to compute the maximum integer
return max(int(x) for x in self._nodes.keys())
def root_node_id(self):
# Compute the downstream root
total = []
for node in self._nodes.values():
total.extend(node.requires)
total.extend(node.build_requires)
roots = set(self._nodes).difference(total)
assert len(roots) == 1
root_id = roots.pop()
return root_id
@staticmethod
def deserialize(data, revisions_enabled):
""" constructs a GraphLock from a json like dict
"""
revs_enabled = data.get("revisions_enabled", False)
if revs_enabled != revisions_enabled:
raise ConanException("Lockfile revisions: '%s' != Current revisions '%s'"
% (revs_enabled, revisions_enabled))
graph_lock = GraphLock(deps_graph=None, revisions_enabled=revisions_enabled)
for id_, node in data["nodes"].items():
graph_lock._nodes[id_] = GraphLockNode.deserialize(node, revisions_enabled)
return graph_lock
def serialize(self):
""" returns the object serialized as a dict of plain python types
that can be converted to json
"""
nodes = OrderedDict() # Serialized ordered, so lockfiles are more deterministic
# Sorted using the IDs as integers
for id_, node in sorted(self._nodes.items(), key=lambda x: int(x[0])):
nodes[id_] = node.serialize()
return {"nodes": nodes,
"revisions_enabled": self._revisions_enabled}
def update_lock(self, new_lock):
""" update the lockfile with the contents of other one that was branched from this
one and had some node re-built. Only missing package_id and PREV information will be
updated, the references must match or it will be an error. The nodes IDS must match too.
"""
for id_, node in new_lock.nodes.items():
current = self._nodes[id_]
if current.ref:
if node.ref.copy_clear_rev() != current.ref.copy_clear_rev():
raise ConanException("Incompatible reference")
if current.package_id is None or current.package_id == PACKAGE_ID_UNKNOWN:
current.package_id = node.package_id
if current.prev is None:
current.prev = node.prev
def pre_lock_node(self, node):
if node.recipe == RECIPE_VIRTUAL:
return
try:
locked_node = self._nodes[node.id]
except KeyError: # If the consumer node is not found, could be a test_package
if node.recipe == RECIPE_CONSUMER:
return
if not self._relaxed:
raise ConanException("The node %s ID %s was not found in the lock"
% (node.ref, node.id))
else:
node.graph_lock_node = locked_node
if locked_node.options is not None: # This was a "partial" one, not a "base" one
node.conanfile.options.values = locked_node.options
def lock_node(self, node, requires, build_requires=False):
""" apply options and constraints on requirements of a node, given the information from
the lockfile. Requires remove their version ranges.
"""
# Important to remove the overrides, they do not need to be locked or evaluated
requires = [r for r in requires if not r.override]
if not node.graph_lock_node:
# For --build-require case, this is the moment the build require can be locked
if build_requires and node.recipe == RECIPE_VIRTUAL:
for require in requires:
node_id = self._find_node_by_requirement(require.ref)
locked_ref = self._nodes[node_id].ref
require.lock(locked_ref, node_id)
# This node is not locked yet, but if it is relaxed, one requirement might
# match the root node of the exising lockfile
# If it is a test_package, with a build_require, it shouldn't even try to find it in
# lock, build_requires are private, if node is not locked, dont lokk for them
# https://github.com/conan-io/conan/issues/8744
# TODO: What if a test_package contains extra requires?
if self._relaxed and not build_requires:
for require in requires:
locked_id = self._match_relaxed_require(require.ref)
if locked_id:
locked_node = self._nodes[locked_id]
require.lock(locked_node.ref, locked_id)
return
locked_node = node.graph_lock_node
if build_requires:
locked_requires = locked_node.build_requires or []
else:
locked_requires = locked_node.requires or []
refs = {(self._nodes[id_].ref.name, self._nodes[id_].context): (self._nodes[id_].ref, id_) for id_ in locked_requires}
for require in requires:
try:
context = require.build_require_context if build_requires else node.context
locked_ref, locked_id = refs[(require.ref.name, context)]
except KeyError:
t = "Build-require" if build_requires else "Require"
msg = "%s '%s' cannot be found in lockfile" % (t, require.ref.name)
if self._relaxed:
node.conanfile.output.warn(msg)
else:
raise ConanException(msg)
else:
require.lock(locked_ref, locked_id)
# Check all refs are locked (not checking build_requires atm, as they come from
# 2 sources (profile, recipe), can't be checked at once
if not self._relaxed and not build_requires:
declared_requires = set([r.ref.name for r in requires])
for require in locked_requires:
req_node = self._nodes[require]
if req_node.ref.name not in declared_requires:
raise ConanException("'%s' locked requirement '%s' not found"
% (str(node.ref), str(req_node.ref)))
def check_locked_build_requires(self, node, package_build_requires, profile_build_requires):
if self._relaxed:
return
locked_node = node.graph_lock_node
if locked_node is None:
return
locked_requires = locked_node.build_requires
if not locked_requires:
return
package_br = [r for r, _ in package_build_requires]
profile_br = [r.name for r, _ in profile_build_requires]
declared_requires = set(package_br + profile_br)
for require in locked_requires:
req_node = self._nodes[require]
if req_node.ref.name not in declared_requires:
raise ConanException("'%s' locked requirement '%s' not found"
% (str(node.ref), str(req_node.ref)))
def python_requires(self, node_id):
if node_id is None and self._relaxed:
return None
return self._nodes[node_id].python_requires
def _match_relaxed_require(self, ref):
assert self._relaxed
assert isinstance(ref, ConanFileReference)
version = ref.version
version_range = None
if version.startswith("[") and version.endswith("]"):
version_range = version[1:-1]
if version_range:
for id_, node in self._nodes.items():
root_ref = node.ref
if (root_ref is not None and ref.name == root_ref.name and
ref.user == root_ref.user and
ref.channel == root_ref.channel):
output = []
result = satisfying([str(root_ref.version)], version_range, output)
if result:
return id_
else:
search_ref = repr(ref)
if ref.revision: # Search by exact ref (with RREV)
node_id = self._find_first(lambda n: n.ref and repr(n.ref) == search_ref)
else: # search by ref without RREV
node_id = self._find_first(lambda n: n.ref and str(n.ref) == search_ref)
if node_id:
return node_id
def _find_first(self, predicate):
""" find the first node in the graph matching the predicate"""
for id_, node in sorted(self._nodes.items()):
if predicate(node):
return id_
def get_consumer(self, ref):
""" given a REF of a conanfile.txt (None) or conanfile.py in user folder,
return the Node of the package in the lockfile that correspond to that
REF, or raise if it cannot find it.
First, search with REF without revisions is done, then approximate search by just name
"""
assert (ref is None or isinstance(ref, ConanFileReference))
# None reference
if ref is None or ref.name is None:
# Is a conanfile.txt consumer
node_id = self._find_first(lambda n: not n.ref and n.path)
if node_id:
return node_id
else:
assert ref.revision is None
repr_ref = repr(ref)
str_ref = str(ref)
node_id = ( # First search by exact ref with RREV
self._find_first(lambda n: n.ref and repr(n.ref) == repr_ref) or
# If not mathing, search by exact ref without RREV
self._find_first(lambda n: n.ref and str(n.ref) == str_ref) or
# Or it could be a local consumer (n.path defined), search only by name
self._find_first(lambda n: n.ref and n.ref.name == ref.name and n.path))
if node_id:
return node_id
if not self._relaxed:
raise ConanException("Couldn't find '%s' in lockfile" % ref.full_str())
def find_require_and_lock(self, reference, conanfile, lockfile_node_id=None):
if lockfile_node_id:
node_id = lockfile_node_id
else:
node_id = self._find_node_by_requirement(reference)
if node_id is None: # relaxed and not found
return
locked_ref = self._nodes[node_id].ref
assert locked_ref is not None
conanfile.requires[reference.name].lock(locked_ref, node_id)
def _find_node_by_requirement(self, ref):
"""
looking for a pkg that will be depended from a "virtual" conanfile
- "conan install zlib/[>1.2]@" Version-range NOT allowed
- "conan install zlib/1.2@ " Exact dep
:param ref:
:return:
"""
assert isinstance(ref, ConanFileReference), "ref '%s' is '%s'!=ConanFileReference" \
% (ref, type(ref))
version = ref.version
if version.startswith("[") and version.endswith("]"):
raise ConanException("Version ranges not allowed in '%s' when using lockfiles"
% str(ref))
# The ``create`` command uses this to install pkg/version --build=pkg
# removing the revision, but it still should match
search_ref = repr(ref)
if ref.revision: # Match should be exact (with RREV)
node_id = self._find_first(lambda n: n.ref and repr(n.ref) == search_ref)
else:
node_id = self._find_first(lambda n: n.ref and str(n.ref) == search_ref)
if node_id:
return node_id
if not self._relaxed:
raise ConanException("Couldn't find '%s' in lockfile" % ref.full_str())
def update_exported_ref(self, node_id, ref):
""" when the recipe is exported, it will complete the missing RREV, otherwise it should
match the existing RREV
"""
lock_node = self._nodes[node_id]
lock_node.ref = ref