-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlabel_diff.py
More file actions
519 lines (440 loc) · 18.1 KB
/
label_diff.py
File metadata and controls
519 lines (440 loc) · 18.1 KB
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
# Labels is a CLI tool to audit, sync, and manage Repository labels.
#
# Copyright (C) 2025 TheSyscall
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
from enum import auto
from enum import Enum
from typing import Any
from typing import cast
from typing import Optional
class Label:
"""
Represents a label with a name, optional description, and color.
This class provides a structure to handle labels which may include a name,
a description, and a color. It also allows instantiation from a dictionary
representation through a class method. Labels have an optional resolved
alias that can be associated with them.
Attributes:
name: The name of the label, which is required.
description: An optional description of the label providing additional
details.
color: An optional color associated with the label.
resolved_alias: An optional resolved alias of type LabelSpec.
"""
def __init__(
self,
name: str,
description: Optional[str],
color: Optional[str],
):
self.name: str = name
self.description: Optional[str] = description
self.color: Optional[str] = color
self.resolved_alias: Optional[LabelSpec] = None
@classmethod
def from_dict(cls, dict: dict[str, Any]) -> Label:
"""
Construct a `Label` instance from a dictionary object. The dictionary
should contain keys corresponding to the attributes required to
initialize the `Label` object. This method simplifies the creation of
`Label` objects when data is available as a dictionary.
Parameters:
dict: A dictionary containing keys and values for initializing a
`Label`. Expected to contain the following keys:
- name (str): The name of the label. This key is mandatory.
- description (Optional[str]): A brief description of the
label. This key is optional.
- color (Optional[str]): A string representing the color of
the label. This key is optional.
Returns:
Label: An instance of `Label` initialized with the values provided
in the dictionary.
"""
return cls(
dict["name"],
dict.get("description"),
dict.get("color"),
)
def to_dict(self) -> dict[str, Any]:
"""
Converts the instance attributes to a dictionary.
The method collects relevant attributes of the object instance and
returns them in a dictionary format with attribute names as keys and
their respective values.
Returns:
dict[str, Any]: A dictionary containing the instance's attributes
"name", "description", and "color" mapped to their
current values.
"""
return {
"name": self.name,
"description": self.description,
"color": self.color,
}
class LabelSpec(Label):
"""
Represents a specified label with additional attributes.
The LabelSpec class extends the basic label functionality by providing
additional attributes, including whether the label is optional and a list
of aliases. It can also be initialized directly from a dictionary, making
it suitable for data-driven use cases.
Attributes:
optional (bool): Indicates if the label is optional.
alias (list[str]): A list of aliases for the label.
"""
def __init__(
self,
name: str,
description: Optional[str],
color: Optional[str],
optional: bool = False,
alias: list[str] = [],
):
super().__init__(name, description, color)
self.optional: bool = optional
self.alias: list[str] = alias
@classmethod
def from_dict(cls, dict: dict[str, Any]) -> LabelSpec:
"""
A factory method to create a LabelSpec instance from a dictionary.
Given a dictionary containing the necessary fields for a LabelSpec,
this method creates and returns a new instance of the LabelSpec class.
Args:
dict (dict[str, Any]): A dictionary containing fields such as
"name", "description", "color", "optional", and "alias".
These fields are used to populate the attributes of the
LabelSpec instance. The "optional" field is set to False if not
provided, and the "alias" field defaults to an empty list if
not included in the dictionary.
Returns:
LabelSpec: A new instance of LabelSpec created with values from the
input dictionary.
"""
return cls(
dict["name"],
dict.get("description"),
dict.get("color"),
dict["optional"] if "optional" in dict else False,
dict["alias"] if "alias" in dict else [],
)
def to_dict(self) -> dict[str, Any]:
"""
Converts the instance attributes to a dictionary.
This method extends the base class's `to_dict` method by adding
additional attributes specific to this class. The returned dictionary
contains all the attributes from the superclass as well as the
`optional` and `alias` attributes specific to this class.
Returns:
dict[str, Any]: A dictionary representation of the instance.
"""
data = super().to_dict()
data["optional"] = self.optional
data["alias"] = self.alias
return data
class LabelDeltaType(Enum):
"""
Enumeration class representing different types of label modifications.
"""
NAME = auto()
DESCRIPTION = auto()
COLOR = auto()
class LabelDelta:
"""
Represents a label delta, containing a specification, an actual label,
and the differences between them.
This class encapsulates the relationship and differences between a
specified label and an actual label based on a predefined delta type.
It is used to manage and identify discrepancies between expected and
observed label data.
"""
def __init__(
self,
spec: LabelSpec,
actual: Label,
delta: list[LabelDeltaType],
):
self.spec: Label = spec
self.actual: Label = actual
self.delta: list[LabelDeltaType] = delta
@classmethod
def from_dict(cls, dict: dict[str, Any]) -> LabelDelta:
delta = []
if "name" in dict["delta"]:
delta.append(LabelDeltaType.NAME)
if "description" in dict["delta"]:
delta.append(LabelDeltaType.DESCRIPTION)
if "color" in dict["delta"]:
delta.append(LabelDeltaType.COLOR)
return cls(
LabelSpec.from_dict(dict["spec"]),
Label.from_dict(dict["actual"]),
delta,
)
def to_dict(self) -> dict[str, Any]:
"""
Converts the instance properties to a dictionary representation.
This method collects the attributes of the instance, specifically
'spec', 'actual', and a calculated 'delta', and returns them in a
dictionary format. The 'delta' list is constructed based on the
specific flags in the `delta` instance property.
Returns:
dict (dict[str, Any]): A dictionary containing the 'spec',
'actual', and 'delta' attributes.
"""
delta = []
if LabelDeltaType.COLOR in self.delta:
delta.append("color")
if LabelDeltaType.DESCRIPTION in self.delta:
delta.append("description")
if LabelDeltaType.NAME in self.delta:
delta.append("name")
return {
"spec": self.spec.to_dict(),
"actual": self.actual.to_dict(),
"delta": delta,
}
class LabelDiff:
"""
Represents a comparison or difference analysis of repository labels.
This class encapsulates the results of a label diff in a repository
context. It includes details about valid labels, missing labels, extra
labels, and any specific differences. Used for analyzing label consistency
in a repository namespace.
Attributes:
namespace: The namespace where the repository is located.
repository: The specific repository being analyzed for label
differences.
valid: A list of labels that are valid and match the expected criteria.
missing: A list of label specifications that are required but missing
in the repository.
extra: A list of labels present in the repository but not expected.
diff: A list of label differences, showing specific changes between
expected and existing labels.
"""
def __init__(
self,
namespace: str,
repository: str,
valid: list[Label],
missing: list[LabelSpec],
extra: list[Label],
diff: list[LabelDelta],
):
self.namespace: str = namespace
self.repository: str = repository
self.valid: list[Label] = valid
self.missing: list[LabelSpec] = missing
self.extra: list[Label] = extra
self.diff: list[LabelDelta] = diff
def is_change(self) -> bool:
"""
Determines if there are any changes based on missing, extra, or
differing items.
This method checks if there are any notable differences by examining
three attributes: missing, extra, and diff. If any of these attributes
contain items, it concludes that changes exist.
Returns:
bool: True if there are any changes (missing, extra, or differing
items), otherwise False.
"""
return (
len(self.missing) > 0 or len(self.extra) > 0 or len(self.diff) > 0
)
@classmethod
def from_dict(cls, dict: dict[str, Any]) -> LabelDiff:
"""
Create a LabelDiff instance from a dictionary.
This method is used to create an instance of the LabelDiff class
by extracting the relevant fields from a dictionary object. It
allows for easy reconstruction of LabelDiff objects from serialized
or stored data.
Parameters:
dict (dict[str, Any]): A dictionary containing the keys required
to initialize the LabelDiff instance.
The keys include:
- "namespace" (str)
- "repository" (str)
- "valid"
- "missing"
- "extra"
- "diff"
Returns:
LabelDiff: An instantiated LabelDiff object populated with the
data from the provided dictionary.
"""
return cls(
dict["namespace"],
dict["repository"],
[Label.from_dict(jdata) for jdata in dict["valid"]],
[LabelSpec.from_dict(jdata) for jdata in dict["missing"]],
[Label.from_dict(jdata) for jdata in dict["extra"]],
[LabelDelta.from_dict(jdata) for jdata in dict["diff"]],
)
def to_dict(self) -> dict[str, Any]:
"""
Converts the object's attributes into a dictionary representation.
The method generates a dictionary that encapsulates various attributes
of the object, including information on valid, missing, extra, and diff
labels. It processes those attributes by also converting any nested
objects within these lists into their respective dictionary forms.
Returns:
dict[str, Any]: A dictionary representation of the object's
attributes.
"""
return {
"namespace": self.namespace,
"repository": self.repository,
"valid": [label.to_dict() for label in self.valid],
"missing": [label.to_dict() for label in self.missing],
"extra": [label.to_dict() for label in self.extra],
"diff": [label.to_dict() for label in self.diff],
}
def get_by_name(
list: list[Label],
name: str,
) -> Optional[Label]:
"""
Searches for a label with a specific name in a given list of labels.
This function iterates through a list of Label objects and returns the
first Label object whose name matches the given name.
If no matching label is found, the function returns None.
Args:
list: A list of Label objects to search within.
name: The name of the Label to search for.
Returns:
The matching Label object if found, or None if no match exists.
"""
for label in list:
if label.name == name:
return label
return None
def get_by_alias(
true_list: list[LabelSpec],
actual_label: Label,
) -> Optional[Label]:
"""
This function retrieves a specific label from a list of label
specifications when the provided actual label's name matches one of the
aliases within a label specification. If no match is found, it returns
None.
Parameters:
true_list (list[LabelSpec]): A list of LabelSpec objects, where each object
contains a list of alias names.
actual_label (Label): An actual label object to be checked against the list
of aliases in the provided LabelSpec objects.
Returns:
Optional[Label]: The LabelSpec object matching the provided actual label's
name within its alias list, or None if no match is found.
"""
for true_label in true_list:
if len(true_label.alias) == 0:
continue
if actual_label.name in true_label.alias:
return true_label
return None
def get_by_alias_reverse(
actual_list: list[Label],
true_label: LabelSpec,
) -> Optional[Label]:
"""
Find and return an item from a list matching alias names.
This function searches through a list of `Label` objects to find an object
whose name matches any alias specified in a `LabelSpec` object. The first
matching object found will be returned. If no alias matches any of the
objects in the list, the function returns `None`.
Arguments:
actual_list: list[Label]
A list of `Label` objects to search for a match.
true_label: LabelSpec
A `LabelSpec` object containing alias names to match against the
`Label` objects in the list.
Returns:
Optional[Label]: The `Label` object that matches one of the aliases, or
`None` if no match is found.
"""
for alias in true_label.alias:
for actual in actual_list:
if actual.name == alias:
return actual
return None
def create_diff(
truth: list[LabelSpec],
actual: list[Label],
namespace: str,
repository: str,
rename_alias: bool = False,
require_optional: bool = False,
) -> LabelDiff:
"""
Create a diff between the expected labels and actual labels in a
repository.
This function compares a list of expected labels (`truth`) with a list of
actual labels (`actual`) to determine differences. It identifies labels
that are missing, extra, or have differences such as mismatched
descriptions, color changes, or name changes.
Arguments:
truth (list[LabelSpec]): A list of expected label specifications to
compare against.
actual (list[Label]): A list of actual labels retrieved to compare to
the expected list.
namespace (str): The namespace of the repository where labels are
managed.
repository (str): The repository where the comparison is performed.
rename_alias (bool): A flag to include label name mismatches due to
aliases in the diff.
require_optional (bool): A flag to enforce consideration of optional
labels.
Returns:
LabelDiff: An object that contains detailed results of the comparison,
including valid labels, missing labels, extra labels, and detected
differences.
"""
valid = []
missing = []
extra = []
diff = []
for true_label in truth:
found_label = get_by_name(actual, true_label.name)
if found_label is None:
found_label = get_by_alias_reverse(actual, true_label)
if found_label is None:
if not require_optional and true_label.optional:
continue
missing.append(true_label)
continue
delta = []
if true_label.description is not None:
if true_label.description != found_label.description:
delta.append(LabelDeltaType.DESCRIPTION)
if true_label.color is not None:
if true_label.color != found_label.color:
delta.append(LabelDeltaType.COLOR)
if rename_alias:
if true_label.name != found_label.name:
delta.append(LabelDeltaType.NAME)
if len(delta) > 0:
diff.append(LabelDelta(true_label, found_label, delta))
continue
if true_label.name != found_label.name:
found_label.resolved_alias = true_label
valid.append(found_label)
for actual_label in actual:
found_label = get_by_name(cast(list[Label], truth), actual_label.name)
if found_label is None:
found_label = get_by_alias(truth, actual_label)
if found_label is None:
extra.append(actual_label)
return LabelDiff(namespace, repository, valid, missing, extra, diff)