librelane 2.4.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of librelane might be problematic. Click here for more details.

Files changed (166) hide show
  1. librelane/__init__.py +38 -0
  2. librelane/__main__.py +470 -0
  3. librelane/__version__.py +43 -0
  4. librelane/common/__init__.py +61 -0
  5. librelane/common/cli.py +75 -0
  6. librelane/common/drc.py +245 -0
  7. librelane/common/generic_dict.py +319 -0
  8. librelane/common/metrics/__init__.py +35 -0
  9. librelane/common/metrics/__main__.py +413 -0
  10. librelane/common/metrics/library.py +354 -0
  11. librelane/common/metrics/metric.py +186 -0
  12. librelane/common/metrics/util.py +279 -0
  13. librelane/common/misc.py +402 -0
  14. librelane/common/ring_buffer.py +63 -0
  15. librelane/common/tcl.py +80 -0
  16. librelane/common/toolbox.py +549 -0
  17. librelane/common/tpe.py +41 -0
  18. librelane/common/types.py +117 -0
  19. librelane/config/__init__.py +32 -0
  20. librelane/config/__main__.py +158 -0
  21. librelane/config/config.py +1025 -0
  22. librelane/config/flow.py +490 -0
  23. librelane/config/pdk_compat.py +255 -0
  24. librelane/config/preprocessor.py +464 -0
  25. librelane/config/removals.py +45 -0
  26. librelane/config/variable.py +722 -0
  27. librelane/container.py +264 -0
  28. librelane/env_info.py +306 -0
  29. librelane/examples/spm/config.yaml +33 -0
  30. librelane/examples/spm/pin_order.cfg +14 -0
  31. librelane/examples/spm/src/impl.sdc +73 -0
  32. librelane/examples/spm/src/signoff.sdc +68 -0
  33. librelane/examples/spm/src/spm.v +73 -0
  34. librelane/examples/spm/verify/spm_tb.v +106 -0
  35. librelane/examples/spm-user_project_wrapper/SPM_example.v +286 -0
  36. librelane/examples/spm-user_project_wrapper/base_sdc_file.sdc +145 -0
  37. librelane/examples/spm-user_project_wrapper/config-tut.json +12 -0
  38. librelane/examples/spm-user_project_wrapper/config.json +13 -0
  39. librelane/examples/spm-user_project_wrapper/defines.v +66 -0
  40. librelane/examples/spm-user_project_wrapper/template.def +7656 -0
  41. librelane/examples/spm-user_project_wrapper/user_project_wrapper.v +123 -0
  42. librelane/flows/__init__.py +24 -0
  43. librelane/flows/builtins.py +18 -0
  44. librelane/flows/classic.py +330 -0
  45. librelane/flows/cli.py +463 -0
  46. librelane/flows/flow.py +985 -0
  47. librelane/flows/misc.py +71 -0
  48. librelane/flows/optimizing.py +179 -0
  49. librelane/flows/sequential.py +367 -0
  50. librelane/flows/synth_explore.py +173 -0
  51. librelane/logging/__init__.py +40 -0
  52. librelane/logging/logger.py +323 -0
  53. librelane/open_pdks_rev +1 -0
  54. librelane/plugins.py +21 -0
  55. librelane/py.typed +0 -0
  56. librelane/scripts/base.sdc +80 -0
  57. librelane/scripts/klayout/Readme.md +2 -0
  58. librelane/scripts/klayout/open_design.py +63 -0
  59. librelane/scripts/klayout/render.py +121 -0
  60. librelane/scripts/klayout/stream_out.py +176 -0
  61. librelane/scripts/klayout/xml_drc_report_to_json.py +45 -0
  62. librelane/scripts/klayout/xor.drc +120 -0
  63. librelane/scripts/magic/Readme.md +1 -0
  64. librelane/scripts/magic/common/read.tcl +114 -0
  65. librelane/scripts/magic/def/antenna_check.tcl +35 -0
  66. librelane/scripts/magic/def/mag.tcl +19 -0
  67. librelane/scripts/magic/def/mag_gds.tcl +81 -0
  68. librelane/scripts/magic/drc.tcl +79 -0
  69. librelane/scripts/magic/extract_spice.tcl +98 -0
  70. librelane/scripts/magic/gds/drc_batch.tcl +74 -0
  71. librelane/scripts/magic/gds/erase_box.tcl +32 -0
  72. librelane/scripts/magic/gds/extras_mag.tcl +47 -0
  73. librelane/scripts/magic/gds/mag_with_pointers.tcl +32 -0
  74. librelane/scripts/magic/get_bbox.tcl +11 -0
  75. librelane/scripts/magic/lef/extras_maglef.tcl +63 -0
  76. librelane/scripts/magic/lef/maglef.tcl +27 -0
  77. librelane/scripts/magic/lef.tcl +57 -0
  78. librelane/scripts/magic/open.tcl +28 -0
  79. librelane/scripts/magic/wrapper.tcl +19 -0
  80. librelane/scripts/netgen/setup.tcl +28 -0
  81. librelane/scripts/odbpy/apply_def_template.py +49 -0
  82. librelane/scripts/odbpy/cell_frequency.py +107 -0
  83. librelane/scripts/odbpy/check_antenna_properties.py +116 -0
  84. librelane/scripts/odbpy/contextualize.py +109 -0
  85. librelane/scripts/odbpy/defutil.py +574 -0
  86. librelane/scripts/odbpy/diodes.py +373 -0
  87. librelane/scripts/odbpy/disconnected_pins.py +305 -0
  88. librelane/scripts/odbpy/exception_codes.py +17 -0
  89. librelane/scripts/odbpy/filter_unannotated.py +100 -0
  90. librelane/scripts/odbpy/io_place.py +482 -0
  91. librelane/scripts/odbpy/label_macro_pins.py +277 -0
  92. librelane/scripts/odbpy/lefutil.py +97 -0
  93. librelane/scripts/odbpy/placers.py +162 -0
  94. librelane/scripts/odbpy/power_utils.py +395 -0
  95. librelane/scripts/odbpy/random_place.py +57 -0
  96. librelane/scripts/odbpy/reader.py +246 -0
  97. librelane/scripts/odbpy/remove_buffers.py +173 -0
  98. librelane/scripts/odbpy/snap_to_grid.py +57 -0
  99. librelane/scripts/odbpy/wire_lengths.py +93 -0
  100. librelane/scripts/openroad/antenna_check.tcl +20 -0
  101. librelane/scripts/openroad/antenna_repair.tcl +31 -0
  102. librelane/scripts/openroad/basic_mp.tcl +24 -0
  103. librelane/scripts/openroad/buffer_list.tcl +10 -0
  104. librelane/scripts/openroad/common/dpl.tcl +24 -0
  105. librelane/scripts/openroad/common/dpl_cell_pad.tcl +26 -0
  106. librelane/scripts/openroad/common/grt.tcl +32 -0
  107. librelane/scripts/openroad/common/io.tcl +476 -0
  108. librelane/scripts/openroad/common/pdn_cfg.tcl +135 -0
  109. librelane/scripts/openroad/common/resizer.tcl +103 -0
  110. librelane/scripts/openroad/common/set_global_connections.tcl +78 -0
  111. librelane/scripts/openroad/common/set_layer_adjustments.tcl +31 -0
  112. librelane/scripts/openroad/common/set_power_nets.tcl +30 -0
  113. librelane/scripts/openroad/common/set_rc.tcl +75 -0
  114. librelane/scripts/openroad/common/set_routing_layers.tcl +30 -0
  115. librelane/scripts/openroad/cts.tcl +80 -0
  116. librelane/scripts/openroad/cut_rows.tcl +24 -0
  117. librelane/scripts/openroad/dpl.tcl +24 -0
  118. librelane/scripts/openroad/drt.tcl +37 -0
  119. librelane/scripts/openroad/fill.tcl +30 -0
  120. librelane/scripts/openroad/floorplan.tcl +145 -0
  121. librelane/scripts/openroad/gpl.tcl +88 -0
  122. librelane/scripts/openroad/grt.tcl +30 -0
  123. librelane/scripts/openroad/gui.tcl +15 -0
  124. librelane/scripts/openroad/insert_buffer.tcl +127 -0
  125. librelane/scripts/openroad/ioplacer.tcl +67 -0
  126. librelane/scripts/openroad/irdrop.tcl +51 -0
  127. librelane/scripts/openroad/pdn.tcl +52 -0
  128. librelane/scripts/openroad/rcx.tcl +32 -0
  129. librelane/scripts/openroad/repair_design.tcl +70 -0
  130. librelane/scripts/openroad/repair_design_postgrt.tcl +48 -0
  131. librelane/scripts/openroad/rsz_timing_postcts.tcl +68 -0
  132. librelane/scripts/openroad/rsz_timing_postgrt.tcl +70 -0
  133. librelane/scripts/openroad/sta/check_macro_instances.tcl +53 -0
  134. librelane/scripts/openroad/sta/corner.tcl +393 -0
  135. librelane/scripts/openroad/tapcell.tcl +25 -0
  136. librelane/scripts/openroad/write_views.tcl +27 -0
  137. librelane/scripts/pyosys/construct_abc_script.py +177 -0
  138. librelane/scripts/pyosys/json_header.py +84 -0
  139. librelane/scripts/pyosys/synthesize.py +493 -0
  140. librelane/scripts/pyosys/ys_common.py +153 -0
  141. librelane/scripts/tclsh/hello.tcl +1 -0
  142. librelane/state/__init__.py +24 -0
  143. librelane/state/__main__.py +61 -0
  144. librelane/state/design_format.py +180 -0
  145. librelane/state/state.py +351 -0
  146. librelane/steps/__init__.py +61 -0
  147. librelane/steps/__main__.py +511 -0
  148. librelane/steps/checker.py +637 -0
  149. librelane/steps/common_variables.py +340 -0
  150. librelane/steps/cvc_rv.py +169 -0
  151. librelane/steps/klayout.py +509 -0
  152. librelane/steps/magic.py +566 -0
  153. librelane/steps/misc.py +160 -0
  154. librelane/steps/netgen.py +253 -0
  155. librelane/steps/odb.py +955 -0
  156. librelane/steps/openroad.py +2433 -0
  157. librelane/steps/openroad_alerts.py +102 -0
  158. librelane/steps/pyosys.py +629 -0
  159. librelane/steps/step.py +1547 -0
  160. librelane/steps/tclstep.py +288 -0
  161. librelane/steps/verilator.py +222 -0
  162. librelane/steps/yosys.py +371 -0
  163. librelane-2.4.0.dev0.dist-info/METADATA +151 -0
  164. librelane-2.4.0.dev0.dist-info/RECORD +166 -0
  165. librelane-2.4.0.dev0.dist-info/WHEEL +4 -0
  166. librelane-2.4.0.dev0.dist-info/entry_points.txt +8 -0
@@ -0,0 +1,279 @@
1
+ # Copyright 2023 Efabless Corporation
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import re
15
+ import textwrap
16
+ from enum import IntEnum
17
+ from dataclasses import dataclass
18
+ from typing import (
19
+ List,
20
+ Mapping,
21
+ Tuple,
22
+ Dict,
23
+ Any,
24
+ Iterable,
25
+ Optional,
26
+ Union,
27
+ )
28
+
29
+ from .metric import Metric, MetricAggregator, MetricComparisonResult
30
+ from ..misc import Filter
31
+
32
+ modifier_rx = re.compile(r"([\w\-]+)\:([\w\-]+)")
33
+
34
+
35
+ class TableVerbosity(IntEnum):
36
+ """
37
+ The verbosity of the table: whether to include everything, just changes, only
38
+ bad changes or only critical change. Or just nothing.
39
+ """
40
+
41
+ NONE = 0
42
+ CRITICAL = 1
43
+ WORSE = 2
44
+ CHANGED = 3
45
+ ALL = 4
46
+
47
+
48
+ def parse_metric_modifiers(metric_name: str) -> Tuple[str, Mapping[str, str]]:
49
+ """
50
+ Parses a metric name into a base and modifiers as specified in
51
+ the METRICS2.1 naming convention.
52
+
53
+ :param metric_name: The name of the metric as generated by a utility.
54
+ :returns: A tuple of the base part as a string, then the modifiers as
55
+ a key-value mapping.
56
+ """
57
+ mn_mut = metric_name.split("__")
58
+ modifiers = {}
59
+ while ":" in mn_mut[-1]:
60
+ key, value = mn_mut.pop().split(":", maxsplit=1)
61
+ modifiers[key] = value
62
+ return "__".join(mn_mut), {k: modifiers[k] for k in reversed(modifiers)}
63
+
64
+
65
+ def aggregate_metrics(
66
+ input: Mapping[str, Any],
67
+ aggregator_by_metric: Optional[
68
+ Mapping[str, Union[MetricAggregator, Metric]]
69
+ ] = None,
70
+ ) -> Dict[str, Any]:
71
+ """
72
+ Takes a set of metrics generated according to the METRICS2.1 naming
73
+ convention.
74
+
75
+ :param input: A mapping of strings to values of metrics.
76
+ :param aggregator_by_metric: A mapping of metric names to either:
77
+ - A tuple of the initial accumulator and reducer to aggregate the values from all modifier metrics
78
+ - A :class:`Metric` class
79
+ :returns: A tuple of the base part as a string, then the modifiers as
80
+ a key-value mapping.
81
+ """
82
+ if aggregator_by_metric is None:
83
+ aggregator_by_metric = Metric.by_name
84
+
85
+ aggregated: Dict[str, Any] = {}
86
+ for name, value in input.items():
87
+ metric_name, modifiers = parse_metric_modifiers(name)
88
+ if len(modifiers) < 1:
89
+ # No modifiers = final aggregate, don't double-represent in sums
90
+ continue
91
+
92
+ modifier_names = list(modifiers.keys())
93
+ dont_aggregate: Iterable[str] = []
94
+ entry = aggregator_by_metric.get(metric_name)
95
+ if isinstance(entry, Metric):
96
+ dont_aggregate = entry.dont_aggregate or []
97
+ entry = entry.aggregator
98
+
99
+ if entry is None:
100
+ continue
101
+
102
+ if len(set(modifier_names).intersection(set(dont_aggregate))):
103
+ continue
104
+
105
+ metric_name_so_far = metric_name
106
+ for modifier in modifier_names:
107
+ start, aggregation_fn = entry
108
+ current = aggregated.get(metric_name_so_far) or start
109
+ aggregated[metric_name_so_far] = aggregation_fn([current, value])
110
+ metric_name_so_far += f"__{modifier}:{modifiers[modifier]}"
111
+
112
+ final_values = dict(input)
113
+ final_values.update(aggregated)
114
+ return final_values
115
+
116
+
117
+ def _key_from_metrics(fields: Iterable[str], metric: str) -> List[str]:
118
+ base, modifiers = parse_metric_modifiers(metric)
119
+ result = []
120
+ for field in fields:
121
+ if field == "":
122
+ result.append(base)
123
+ else:
124
+ result.append(modifiers.get(field, ""))
125
+ return result
126
+
127
+
128
+ class MetricDiff(object):
129
+ """
130
+ Aggregates a number of ``MetricComparisonResult`` and allows a number of
131
+ functions to be performed on them.
132
+
133
+ :param differences: The metric comparison results.
134
+ """
135
+
136
+ @dataclass
137
+ class MetricStatistics:
138
+ """
139
+ A glorified namespace encapsulating a number of statistics of
140
+ :class:`MetricDiff`.
141
+
142
+ Should be generated using :meth:`MetricDiff.stats`.
143
+
144
+ :param better: The number of datapoints that represent a positive change.
145
+ :param worse: The number of datapoints that represent a negative change.
146
+ :param critical: The number of changes for critical metrics.
147
+ :param unchanged: Values that are unchanged.
148
+ """
149
+
150
+ better: int = 0
151
+ worse: int = 0
152
+ critical: int = 0
153
+ unchanged: int = 0
154
+
155
+ differences: List[MetricComparisonResult]
156
+
157
+ def __init__(self, differences: Iterable[MetricComparisonResult]) -> None:
158
+ self.differences = list(differences)
159
+
160
+ def render_md(
161
+ self,
162
+ sort_by: Optional[Iterable[str]] = None,
163
+ table_verbosity: TableVerbosity = TableVerbosity.ALL,
164
+ ) -> str:
165
+ """
166
+ :param sort_by: A list of tuples corresponding to modifiers to sort
167
+ metrics ascendingly by.
168
+ :param table_verbosity: The verbosity of the table: whether to include everything, just changes, only bad changes or only critical changes. Or just nothing.
169
+ :returns: A table of the differences in Markdown format.
170
+ """
171
+ if table_verbosity == TableVerbosity.NONE:
172
+ return ""
173
+
174
+ differences = self.differences
175
+ if fields := sort_by:
176
+ differences = sorted(
177
+ differences,
178
+ key=lambda x: _key_from_metrics(fields, x.metric_name), # type: ignore # (mypy bug)
179
+ )
180
+
181
+ table = ""
182
+
183
+ changed = []
184
+ worse = []
185
+ critical = []
186
+ remaining = []
187
+
188
+ for row in differences:
189
+ if row.critical is True:
190
+ critical.append(row)
191
+ elif row.better is False:
192
+ worse.append(row)
193
+ elif row.is_changed():
194
+ changed.append(row)
195
+ else:
196
+ remaining.append(row)
197
+
198
+ listed_differences: List[MetricComparisonResult] = []
199
+ if table_verbosity >= TableVerbosity.CRITICAL:
200
+ listed_differences += critical
201
+ if table_verbosity >= TableVerbosity.WORSE:
202
+ listed_differences += worse
203
+ if table_verbosity >= TableVerbosity.CHANGED:
204
+ listed_differences += changed
205
+ if table_verbosity >= TableVerbosity.ALL:
206
+ listed_differences += remaining
207
+
208
+ if len(listed_differences) > 0:
209
+ table = textwrap.dedent(
210
+ f"""
211
+ | {'Metric':<70} | {'Before':<10} | {'After':<10} | {'Delta':<20} |
212
+ | {'-':<70} | {'-':<10} | {'-':<10} | {'-':<20} |
213
+ """
214
+ )
215
+
216
+ for row in listed_differences:
217
+ before, after, delta = row.format_values()
218
+ emoji = ""
219
+ if row.better is not None:
220
+ if row.better:
221
+ emoji = " ⭕"
222
+ else:
223
+ emoji = " ❗"
224
+ if row.critical and row.is_changed():
225
+ emoji = " ‼️"
226
+ table += f"| {row.metric_name:<70} | {before:<10} | {after:<10} | {f'{delta}{emoji}':<20} |\n"
227
+
228
+ return table
229
+
230
+ def stats(self) -> MetricStatistics:
231
+ """
232
+ :returns: A :class:`MetricStatistics` object based on this aggregate.
233
+ """
234
+ stats = MetricDiff.MetricStatistics()
235
+ for row in self.differences:
236
+ if not row.is_changed():
237
+ stats.unchanged += 1
238
+ elif row.better is not None:
239
+ if row.better:
240
+ stats.better += 1
241
+ else:
242
+ stats.worse += 1
243
+ if row.critical:
244
+ stats.critical += 1
245
+ return stats
246
+
247
+ @classmethod
248
+ def from_metrics(
249
+ Self,
250
+ gold: dict,
251
+ new: dict,
252
+ significant_figures: int,
253
+ filter: Filter = Filter(["*"]),
254
+ ) -> "MetricDiff":
255
+ """
256
+ Creates a :class:`MetricDiff` object from two sets of metrics.
257
+
258
+ :param gold: The "gold-standard" metrics to compare against
259
+ :param new: The metrics being evaluated
260
+ :param filter: A :class:`Filter` for the names of the metrics to include
261
+ or exclude certain metrics.
262
+ :returns: The aggregate of the differences between gold and good
263
+ """
264
+
265
+ def generator(g, n):
266
+ for metric in filter.filter(sorted(n.keys())):
267
+ if metric not in g:
268
+ continue
269
+ base_metric, modifiers = parse_metric_modifiers(metric)
270
+ lhs_value, rhs_value = g[metric], n[metric]
271
+ if type(lhs_value) != type(rhs_value):
272
+ lhs_value = type(rhs_value)(lhs_value)
273
+
274
+ if metric_object := Metric.by_name.get(base_metric):
275
+ yield metric_object.compare(
276
+ lhs_value, rhs_value, significant_figures, modifiers=modifiers
277
+ )
278
+
279
+ return MetricDiff(generator(gold, new))
@@ -0,0 +1,402 @@
1
+ # Copyright 2023 Efabless Corporation
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import os
15
+ import re
16
+ import glob
17
+ import gzip
18
+ import typing
19
+ import fnmatch
20
+ import pathlib
21
+ import unicodedata
22
+ from math import inf
23
+ from typing import (
24
+ Any,
25
+ Generator,
26
+ Iterable,
27
+ List,
28
+ TypeVar,
29
+ Optional,
30
+ SupportsFloat,
31
+ Union,
32
+ )
33
+
34
+ import httpx
35
+
36
+ from .types import AnyPath, Path
37
+ from ..__version__ import __version__
38
+
39
+ T = TypeVar("T")
40
+
41
+
42
+ def idem(obj: T, *args, **kwargs) -> T:
43
+ """
44
+ :returns: the parameter ``obj`` unchanged. Useful for some lambdas.
45
+ """
46
+ return obj
47
+
48
+
49
+ def get_librelane_root() -> str:
50
+ """
51
+ Returns the root LibreLane folder, i.e., the folder containing the
52
+ ``__init__.py``.
53
+ """
54
+ return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
55
+
56
+
57
+ def get_script_dir() -> str:
58
+ """
59
+ Gets the LibreLane tool `scripts` directory.
60
+
61
+ :meta private:
62
+ """
63
+ return os.path.join(
64
+ get_librelane_root(),
65
+ "scripts",
66
+ )
67
+
68
+
69
+ def get_opdks_rev() -> str:
70
+ """
71
+ Gets the Open_PDKs revision confirmed compatible with this version of LibreLane.
72
+ """
73
+ return (
74
+ open(os.path.join(get_librelane_root(), "open_pdks_rev"), encoding="utf8")
75
+ .read()
76
+ .strip()
77
+ )
78
+
79
+
80
+ # The following code snippet has been adapted under the following license:
81
+ #
82
+ # Copyright (c) Django Software Foundation and individual contributors.
83
+ # All rights reserved.
84
+
85
+ # Redistribution and use in source and binary forms, with or without modification,
86
+ # are permitted provided that the following conditions are met:
87
+
88
+ # 1. Redistributions of source code must retain the above copyright notice,
89
+ # this list of conditions and the following disclaimer.
90
+
91
+ # 2. Redistributions in binary form must reproduce the above copyright
92
+ # notice, this list of conditions and the following disclaimer in the
93
+ # documentation and/or other materials provided with the distribution.
94
+
95
+ # 3. Neither the name of Django nor the names of its contributors may be used
96
+ # to endorse or promote products derived from this software without
97
+ # specific prior written permission.
98
+
99
+
100
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
101
+ # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
102
+ # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
103
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
104
+ # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
105
+ # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
106
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
107
+ # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
108
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
109
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
110
+ def slugify(value: str, lower: bool = False) -> str:
111
+ """
112
+ :param value: Input string
113
+ :returns: The input string converted to lower case, with all characters
114
+ except alphanumerics, underscores and hyphens removed, and spaces and\
115
+ dots converted into hyphens.
116
+
117
+ Leading and trailing whitespace is stripped.
118
+ """
119
+ if lower:
120
+ value = value.lower()
121
+ value = (
122
+ unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii")
123
+ )
124
+ value = re.sub(r"[^\w\s\-\.]", "", value).strip().lower()
125
+ return re.sub(r"[\s\.]+", "-", value)
126
+
127
+
128
+ def protected(method):
129
+ """A decorator to indicate protected methods.
130
+
131
+ It dynamically adds a statement to the effect in the docstring as well
132
+ as setting an attribute, ``protected``, to ``True``, but has no other effects.
133
+
134
+ :param f: Method to mark as protected
135
+ """
136
+ if method.__doc__ is None:
137
+ method.__doc__ = ""
138
+ method.__doc__ = "**protected**\n" + method.__doc__
139
+
140
+ setattr(method, "protected", True)
141
+ return method
142
+
143
+
144
+ final = typing.final
145
+ final.__doc__ = """A decorator to indicate final methods and final classes.
146
+
147
+ Use this decorator to indicate to type checkers that the decorated
148
+ method cannot be overridden, and decorated class cannot be subclassed.
149
+ For example:
150
+
151
+
152
+ .. code-block:: python
153
+
154
+ class Base:
155
+ @final
156
+ def done(self) -> None:
157
+ ...
158
+ class Sub(Base):
159
+ def done(self) -> None: # Error reported by type checker
160
+ ...
161
+
162
+ @final
163
+ class Leaf:
164
+ ...
165
+ class Other(Leaf): # Error reported by type checker
166
+ ...
167
+
168
+ There is no runtime checking of these properties.
169
+ """
170
+
171
+
172
+ def mkdirp(path: typing.Union[str, os.PathLike]):
173
+ """
174
+ Attempts to create a directory and all of its parents.
175
+
176
+ Does not fail if the directory already exists, however, it does fail
177
+ if it is unable to create any of the components and/or if the path
178
+ already exists as a file.
179
+
180
+ :param path: A filesystem path for the directory
181
+ """
182
+ return pathlib.Path(path).mkdir(parents=True, exist_ok=True)
183
+
184
+
185
+ class zip_first(object):
186
+ """
187
+ Works like ``zip_longest`` if |a| > |b| and ``zip`` if |a| <= |b|.
188
+ """
189
+
190
+ def __init__(self, a: Iterable, b: Iterable, fillvalue: Any) -> None:
191
+ self.a = a
192
+ self.b = b
193
+ self.fillvalue = fillvalue
194
+
195
+ def __iter__(self):
196
+ self.iter_a = iter(self.a)
197
+ self.iter_b = iter(self.b)
198
+ return self
199
+
200
+ def __next__(self):
201
+ a = next(self.iter_a)
202
+ b = self.fillvalue
203
+ try:
204
+ b = next(self.iter_b)
205
+ except StopIteration:
206
+ pass
207
+ return (a, b)
208
+
209
+
210
+ def format_size(byte_count: int) -> str:
211
+ units = [
212
+ "B",
213
+ "KiB",
214
+ "MiB",
215
+ "GiB",
216
+ "TiB",
217
+ "PiB",
218
+ "EiB",
219
+ # TODO: update LibreLane when zebibytes are a thing
220
+ ]
221
+
222
+ tracker = 0
223
+ so_far = byte_count
224
+ while (so_far // 1024) > 0 and tracker < (len(units) - 1):
225
+ tracker += 1
226
+ so_far //= 1024
227
+
228
+ return f"{so_far}{units[tracker]}"
229
+
230
+
231
+ def format_elapsed_time(elapsed_seconds: SupportsFloat) -> str:
232
+ """
233
+ :param elapsed_seconds: Total time elapsed in seconds
234
+ :returns: A string in the format ``{hours}:{minutes}:{seconds}:{milliseconds}``
235
+ """
236
+ elapsed_seconds = float(elapsed_seconds)
237
+
238
+ hours = int(elapsed_seconds // 3600)
239
+ leftover = elapsed_seconds % 3600
240
+
241
+ minutes = int(leftover // 60)
242
+ leftover = leftover % 60
243
+
244
+ seconds = int(leftover // 1)
245
+ milliseconds = int((leftover % 1) * 1000)
246
+
247
+ return f"{hours:02}:{minutes:02}:{seconds:02}.{milliseconds:03}"
248
+
249
+
250
+ class Filter(object):
251
+ """
252
+ Encapsulates commonly used wildcard-based filtering functions into an object.
253
+
254
+ :param filters: A list of a wildcards supporting the
255
+ `fnmatch spec <https://docs.python.org/3.10/library/fnmatch.html>`_.
256
+
257
+ The wildcards will be split into an "allow" and "deny" list based on whether
258
+ the filter is prefixed with a ``!``.
259
+ """
260
+
261
+ def __init__(self, filters: Iterable[str]):
262
+ self.allow = []
263
+ self.deny = []
264
+ for filter in filters:
265
+ if filter.startswith("!"):
266
+ self.deny.append(filter[1:])
267
+ else:
268
+ self.allow.append(filter)
269
+
270
+ def get_matching_wildcards(self, input: str) -> Generator[str, Any, None]:
271
+ """
272
+ :param input: An input to match wildcards against.
273
+ :returns: An iterable object for *all* wildcards in the allow list
274
+ accepting ``input``, and *all* wildcards in the deny list rejecting
275
+ ``input``.
276
+ """
277
+ for wildcard in self.allow:
278
+ if fnmatch.fnmatch(input, wildcard):
279
+ yield wildcard
280
+ for wildcard in self.deny:
281
+ if not fnmatch.fnmatch(input, wildcard):
282
+ yield wildcard
283
+
284
+ def match(self, input: str) -> bool:
285
+ """
286
+ :param input: An input string to either accept or reject
287
+ :returns: A boolean indicating whether the input:
288
+ * Has matched at least one wildcard in the allow list
289
+ * Has matched exactly 0 inputs in the deny list
290
+ """
291
+ allowed = False
292
+ for wildcard in self.allow:
293
+ if fnmatch.fnmatch(input, wildcard):
294
+ allowed = True
295
+ break
296
+ for wildcard in self.deny:
297
+ if fnmatch.fnmatch(input, wildcard):
298
+ allowed = False
299
+ break
300
+ return allowed
301
+
302
+ def filter(
303
+ self,
304
+ inputs: Iterable[str],
305
+ ) -> Generator[str, Any, None]:
306
+ """
307
+ :param inputs: A series of inputs to filter according to the wildcards.
308
+ :returns: An iterable object of any values in ``inputs`` that:
309
+ * Have matched at least one wildcard in the allow list
310
+ * Have matched exactly 0 inputs in the deny list
311
+ """
312
+ for input in inputs:
313
+ if self.match(input):
314
+ yield input
315
+
316
+
317
+ def get_latest_file(in_path: Union[str, os.PathLike], filename: str) -> Optional[Path]:
318
+ """
319
+ :param in_path: A directory to search in
320
+ :param filename: The final filename
321
+ :returns: The latest file matching the parameters, by modification time
322
+ """
323
+ glob_results = glob.glob(os.path.join(in_path, "**", filename), recursive=True)
324
+ latest_time = -inf
325
+ latest_json = None
326
+ for result in glob_results:
327
+ time = os.path.getmtime(result)
328
+ if time > latest_time:
329
+ latest_time = time
330
+ latest_json = Path(result)
331
+
332
+ return latest_json
333
+
334
+
335
+ def get_httpx_session(token: Optional[str] = None) -> httpx.Client:
336
+ """
337
+ Creates an ``httpx`` session client that follows redirects and has the
338
+ User-Agent header set to ``librelane/{__version__}``.
339
+
340
+ :param token: If this parameter is non-None and not empty, another header,
341
+ Authorization: Bearer {token}, is included.
342
+ :returns: The created client
343
+ """
344
+ session = httpx.Client(follow_redirects=True)
345
+ headers_raw = {"User-Agent": f"librelane/{__version__}"}
346
+ if token is not None and token.strip() != "":
347
+ headers_raw["Authorization"] = f"Bearer {token}"
348
+ session.headers = httpx.Headers(headers_raw)
349
+ return session
350
+
351
+
352
+ def process_list_file(from_file: AnyPath) -> List[str]:
353
+ """
354
+ Convenience function to process text files in a ``.gitignore``-style format,
355
+ i.e., those where the lines may be:
356
+
357
+ * A list element
358
+ * A comment prefixed with ``#``
359
+ * Blank
360
+
361
+ :param from_file: The input text file.
362
+ :returns: A list of the strings listed in the file, ignoring lines
363
+ prefixed with a ``#`` and empty lines.
364
+ """
365
+ excluded_cells = []
366
+ list_str = open(str(from_file), encoding="utf8").read()
367
+ for line in list_str.splitlines():
368
+ line = line.strip()
369
+ if line == "":
370
+ continue
371
+ if line[0] == "#":
372
+ continue
373
+ excluded_cells.append(line)
374
+ return excluded_cells
375
+
376
+
377
+ def _get_process_limit() -> int:
378
+ return int(os.getenv("_OPENLANE_MAX_CORES", os.cpu_count() or 1))
379
+
380
+
381
+ def gzopen(filename, mode="rt"):
382
+ """
383
+ This method (tries to?) emulate the gzopen from the Linux Standard Base,
384
+ specifically this part:
385
+
386
+ If path refers to an uncompressed file, and mode refers to a read mode,
387
+ gzopen() shall attempt to open the file and return a gzFile object suitable
388
+ for reading directly from the file without any decompression.
389
+
390
+ gzip.open does not have this behavior.
391
+ """
392
+ try:
393
+ g = gzip.open(filename, mode=mode)
394
+ # Incredibly, it won't actually try to figure out if it's a gzipped
395
+ # file until you try to read from it.
396
+ if "r" in mode:
397
+ g.read(1)
398
+ g.seek(0)
399
+ return g
400
+ except gzip.BadGzipFile:
401
+ g.close()
402
+ return open(filename, mode=mode)