siliconcompiler 0.34.1__py3-none-any.whl → 0.34.3__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.
Files changed (129) hide show
  1. siliconcompiler/__init__.py +23 -4
  2. siliconcompiler/__main__.py +1 -7
  3. siliconcompiler/_metadata.py +1 -1
  4. siliconcompiler/apps/_common.py +104 -23
  5. siliconcompiler/apps/sc.py +4 -8
  6. siliconcompiler/apps/sc_dashboard.py +6 -4
  7. siliconcompiler/apps/sc_install.py +10 -6
  8. siliconcompiler/apps/sc_issue.py +7 -5
  9. siliconcompiler/apps/sc_remote.py +1 -1
  10. siliconcompiler/apps/sc_server.py +9 -14
  11. siliconcompiler/apps/sc_show.py +7 -6
  12. siliconcompiler/apps/smake.py +130 -94
  13. siliconcompiler/apps/utils/replay.py +4 -7
  14. siliconcompiler/apps/utils/summarize.py +3 -5
  15. siliconcompiler/asic.py +420 -0
  16. siliconcompiler/checklist.py +25 -2
  17. siliconcompiler/cmdlineschema.py +534 -0
  18. siliconcompiler/constraints/__init__.py +17 -0
  19. siliconcompiler/constraints/asic_component.py +378 -0
  20. siliconcompiler/constraints/asic_floorplan.py +449 -0
  21. siliconcompiler/constraints/asic_pins.py +489 -0
  22. siliconcompiler/constraints/asic_timing.py +517 -0
  23. siliconcompiler/core.py +10 -35
  24. siliconcompiler/data/templates/tcl/manifest.tcl.j2 +8 -0
  25. siliconcompiler/dependencyschema.py +96 -202
  26. siliconcompiler/design.py +327 -241
  27. siliconcompiler/filesetschema.py +250 -0
  28. siliconcompiler/flowgraph.py +298 -106
  29. siliconcompiler/fpga.py +124 -1
  30. siliconcompiler/library.py +331 -0
  31. siliconcompiler/metric.py +327 -92
  32. siliconcompiler/metrics/__init__.py +7 -0
  33. siliconcompiler/metrics/asic.py +245 -0
  34. siliconcompiler/metrics/fpga.py +220 -0
  35. siliconcompiler/package/__init__.py +391 -67
  36. siliconcompiler/package/git.py +92 -16
  37. siliconcompiler/package/github.py +114 -22
  38. siliconcompiler/package/https.py +79 -16
  39. siliconcompiler/packageschema.py +341 -16
  40. siliconcompiler/pathschema.py +255 -0
  41. siliconcompiler/pdk.py +566 -1
  42. siliconcompiler/project.py +1460 -0
  43. siliconcompiler/record.py +38 -1
  44. siliconcompiler/remote/__init__.py +5 -2
  45. siliconcompiler/remote/client.py +11 -6
  46. siliconcompiler/remote/schema.py +5 -23
  47. siliconcompiler/remote/server.py +41 -54
  48. siliconcompiler/report/__init__.py +3 -3
  49. siliconcompiler/report/dashboard/__init__.py +48 -14
  50. siliconcompiler/report/dashboard/cli/__init__.py +99 -21
  51. siliconcompiler/report/dashboard/cli/board.py +364 -179
  52. siliconcompiler/report/dashboard/web/__init__.py +90 -12
  53. siliconcompiler/report/dashboard/web/components/__init__.py +219 -240
  54. siliconcompiler/report/dashboard/web/components/flowgraph.py +49 -26
  55. siliconcompiler/report/dashboard/web/components/graph.py +139 -100
  56. siliconcompiler/report/dashboard/web/layouts/__init__.py +29 -1
  57. siliconcompiler/report/dashboard/web/layouts/_common.py +38 -2
  58. siliconcompiler/report/dashboard/web/layouts/vertical_flowgraph.py +39 -26
  59. siliconcompiler/report/dashboard/web/layouts/vertical_flowgraph_node_tab.py +50 -50
  60. siliconcompiler/report/dashboard/web/layouts/vertical_flowgraph_sac_tabs.py +49 -46
  61. siliconcompiler/report/dashboard/web/state.py +141 -14
  62. siliconcompiler/report/dashboard/web/utils/__init__.py +79 -16
  63. siliconcompiler/report/dashboard/web/utils/file_utils.py +74 -11
  64. siliconcompiler/report/dashboard/web/viewer.py +25 -1
  65. siliconcompiler/report/report.py +5 -2
  66. siliconcompiler/report/summary_image.py +29 -11
  67. siliconcompiler/scheduler/__init__.py +9 -1
  68. siliconcompiler/scheduler/docker.py +81 -4
  69. siliconcompiler/scheduler/run_node.py +37 -20
  70. siliconcompiler/scheduler/scheduler.py +211 -36
  71. siliconcompiler/scheduler/schedulernode.py +394 -60
  72. siliconcompiler/scheduler/send_messages.py +77 -29
  73. siliconcompiler/scheduler/slurm.py +76 -12
  74. siliconcompiler/scheduler/taskscheduler.py +142 -21
  75. siliconcompiler/schema/__init__.py +0 -4
  76. siliconcompiler/schema/baseschema.py +338 -59
  77. siliconcompiler/schema/editableschema.py +14 -6
  78. siliconcompiler/schema/journal.py +28 -17
  79. siliconcompiler/schema/namedschema.py +22 -14
  80. siliconcompiler/schema/parameter.py +89 -28
  81. siliconcompiler/schema/parametertype.py +2 -0
  82. siliconcompiler/schema/parametervalue.py +258 -15
  83. siliconcompiler/schema/safeschema.py +25 -2
  84. siliconcompiler/schema/schema_cfg.py +23 -19
  85. siliconcompiler/schema/utils.py +2 -2
  86. siliconcompiler/schema_obj.py +24 -5
  87. siliconcompiler/tool.py +1131 -265
  88. siliconcompiler/tools/bambu/__init__.py +41 -0
  89. siliconcompiler/tools/builtin/concatenate.py +2 -2
  90. siliconcompiler/tools/builtin/minimum.py +2 -1
  91. siliconcompiler/tools/builtin/mux.py +2 -1
  92. siliconcompiler/tools/builtin/nop.py +2 -1
  93. siliconcompiler/tools/builtin/verify.py +2 -1
  94. siliconcompiler/tools/klayout/__init__.py +95 -0
  95. siliconcompiler/tools/openroad/__init__.py +289 -0
  96. siliconcompiler/tools/openroad/scripts/apr/preamble.tcl +3 -0
  97. siliconcompiler/tools/openroad/scripts/apr/sc_detailed_route.tcl +7 -2
  98. siliconcompiler/tools/openroad/scripts/apr/sc_global_route.tcl +8 -4
  99. siliconcompiler/tools/openroad/scripts/apr/sc_init_floorplan.tcl +9 -5
  100. siliconcompiler/tools/openroad/scripts/common/write_images.tcl +5 -1
  101. siliconcompiler/tools/slang/__init__.py +1 -1
  102. siliconcompiler/tools/slang/elaborate.py +2 -1
  103. siliconcompiler/tools/vivado/scripts/sc_run.tcl +1 -1
  104. siliconcompiler/tools/vivado/scripts/sc_syn_fpga.tcl +8 -1
  105. siliconcompiler/tools/vivado/syn_fpga.py +6 -0
  106. siliconcompiler/tools/vivado/vivado.py +35 -2
  107. siliconcompiler/tools/vpr/__init__.py +150 -0
  108. siliconcompiler/tools/yosys/__init__.py +369 -1
  109. siliconcompiler/tools/yosys/scripts/procs.tcl +0 -1
  110. siliconcompiler/toolscripts/_tools.json +5 -10
  111. siliconcompiler/utils/__init__.py +66 -0
  112. siliconcompiler/utils/flowgraph.py +2 -2
  113. siliconcompiler/utils/issue.py +2 -1
  114. siliconcompiler/utils/logging.py +14 -0
  115. siliconcompiler/utils/multiprocessing.py +256 -0
  116. siliconcompiler/utils/showtools.py +10 -0
  117. {siliconcompiler-0.34.1.dist-info → siliconcompiler-0.34.3.dist-info}/METADATA +6 -6
  118. {siliconcompiler-0.34.1.dist-info → siliconcompiler-0.34.3.dist-info}/RECORD +122 -115
  119. {siliconcompiler-0.34.1.dist-info → siliconcompiler-0.34.3.dist-info}/entry_points.txt +3 -0
  120. siliconcompiler/schema/cmdlineschema.py +0 -250
  121. siliconcompiler/schema/packageschema.py +0 -101
  122. siliconcompiler/toolscripts/rhel8/install-slang.sh +0 -40
  123. siliconcompiler/toolscripts/rhel9/install-slang.sh +0 -40
  124. siliconcompiler/toolscripts/ubuntu20/install-slang.sh +0 -47
  125. siliconcompiler/toolscripts/ubuntu22/install-slang.sh +0 -37
  126. siliconcompiler/toolscripts/ubuntu24/install-slang.sh +0 -37
  127. {siliconcompiler-0.34.1.dist-info → siliconcompiler-0.34.3.dist-info}/WHEEL +0 -0
  128. {siliconcompiler-0.34.1.dist-info → siliconcompiler-0.34.3.dist-info}/licenses/LICENSE +0 -0
  129. {siliconcompiler-0.34.1.dist-info → siliconcompiler-0.34.3.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,24 @@
1
+ """
2
+ This module defines the core path resolution system for SiliconCompiler packages.
3
+
4
+ It provides a flexible mechanism to locate data sources, whether they are on
5
+ the local filesystem, remote servers, or part of a Python package. The system
6
+ is designed to be extensible, allowing new resolver types to be added as plugins.
7
+ It also includes robust caching and locking mechanisms for handling remote data
8
+ efficiently and safely in multi-process and multi-threaded environments.
9
+ """
1
10
  import contextlib
2
11
  import functools
12
+ import hashlib
3
13
  import importlib
4
14
  import json
5
15
  import logging
6
16
  import os
17
+ import random
7
18
  import re
8
19
  import time
9
20
  import threading
21
+ import uuid
10
22
 
11
23
  import os.path
12
24
 
@@ -19,6 +31,7 @@ from siliconcompiler.utils import get_plugins
19
31
 
20
32
 
21
33
  def path(chip, package):
34
+ """DEPRECATED: Use chip.get_resolver(package).get_path() instead."""
22
35
  import warnings
23
36
  warnings.warn("The 'path' method has been deprecated",
24
37
  DeprecationWarning)
@@ -31,10 +44,7 @@ def register_python_data_source(chip,
31
44
  alternative_path,
32
45
  alternative_ref=None,
33
46
  python_module_path_append=None):
34
- '''
35
- Helper function to register a python module as data source with an alternative in case
36
- the module is not installed in an editable state
37
- '''
47
+ """DEPRECATED: Use PythonPathResolver.register_source() instead."""
38
48
  import warnings
39
49
  warnings.warn("The 'register_python_data_source' method was renamed "
40
50
  "PythonPathResolver.register_source",
@@ -47,16 +57,36 @@ def register_python_data_source(chip,
47
57
 
48
58
 
49
59
  class Resolver:
60
+ """
61
+ Abstract base class for all data source resolvers.
62
+
63
+ This class defines the common interface for locating and accessing data
64
+ from various sources. It includes a caching mechanism to avoid redundant
65
+ resolutions within a single run.
66
+
67
+ Attributes:
68
+ name (str): The name of the data package being resolved.
69
+ root (object): The root object (typically a Chip) providing context,
70
+ such as environment variables and the working directory.
71
+ source (str): The URI or path specifying the data source.
72
+ reference (str): A version, commit hash, or tag for remote sources.
73
+ """
50
74
  _RESOLVERS_LOCK = threading.Lock()
51
75
  _RESOLVERS = {}
52
76
 
77
+ __CACHE_LOCK = threading.Lock()
78
+ __CACHE = {}
79
+
53
80
  def __init__(self, name, root, source, reference=None):
81
+ """
82
+ Initializes the Resolver.
83
+ """
54
84
  self.__name = name
55
85
  self.__root = root
56
86
  self.__source = source
57
87
  self.__reference = reference
58
88
  self.__changed = False
59
- self.__cache = {}
89
+ self.__cacheid = None
60
90
 
61
91
  if self.__root and hasattr(self.__root, "logger"):
62
92
  self.__logger = self.__root.logger.getChild(f"resolver-{self.name}")
@@ -65,6 +95,13 @@ class Resolver:
65
95
 
66
96
  @staticmethod
67
97
  def populate_resolvers():
98
+ """
99
+ Scans for and registers all available resolver plugins.
100
+
101
+ This method populates the internal `_RESOLVERS` dictionary with both
102
+ built-in resolvers (file, key, python) and any resolvers provided
103
+ by external plugins.
104
+ """
68
105
  with Resolver._RESOLVERS_LOCK:
69
106
  Resolver._RESOLVERS.clear()
70
107
 
@@ -80,6 +117,18 @@ class Resolver:
80
117
 
81
118
  @staticmethod
82
119
  def find_resolver(source):
120
+ """
121
+ Finds the appropriate resolver class for a given source URI.
122
+
123
+ Args:
124
+ source (str): The source URI (e.g., 'file:///path/to/file', 'git://...').
125
+
126
+ Returns:
127
+ Resolver: The resolver class capable of handling the source.
128
+
129
+ Raises:
130
+ ValueError: If no suitable resolver is found for the URI scheme.
131
+ """
83
132
  if os.path.isabs(source):
84
133
  return FileResolver
85
134
 
@@ -91,71 +140,177 @@ class Resolver:
91
140
  if url.scheme in Resolver._RESOLVERS:
92
141
  return Resolver._RESOLVERS[url.scheme]
93
142
 
94
- raise ValueError(f"{source} is not supported")
143
+ raise ValueError(f"Source URI '{source}' is not supported")
95
144
 
96
145
  @property
97
146
  def name(self) -> str:
147
+ """The name of the data package being resolved."""
98
148
  return self.__name
99
149
 
100
150
  @property
101
151
  def root(self):
152
+ """The root object (e.g., Chip) providing context."""
102
153
  return self.__root
103
154
 
104
155
  @property
105
156
  def logger(self) -> logging.Logger:
157
+ """The logger instance for this resolver."""
106
158
  return self.__logger
107
159
 
108
160
  @property
109
161
  def source(self) -> str:
162
+ """The URI or path specifying the data source."""
110
163
  return self.__source
111
164
 
112
165
  @property
113
166
  def reference(self) -> str:
167
+ """A version, commit hash, or tag for the source."""
114
168
  return self.__reference
115
169
 
116
170
  @property
117
171
  def urlparse(self) -> url_parse.ParseResult:
172
+ """The parsed URL of the source after environment variable expansion."""
118
173
  return url_parse.urlparse(self.__resolve_env(self.source))
119
174
 
120
175
  @property
121
176
  def urlscheme(self) -> str:
177
+ """The scheme of the source URL (e.g., 'file', 'git')."""
122
178
  return self.urlparse.scheme
123
179
 
124
180
  @property
125
181
  def urlpath(self) -> str:
182
+ """The path component of the source URL."""
126
183
  return self.urlparse.netloc
127
184
 
128
185
  @property
129
- def changed(self):
186
+ def changed(self) -> bool:
187
+ """
188
+ Indicates if the resolved data has changed (e.g., was newly fetched).
189
+
190
+ This flag is reset to False after being read.
191
+ """
130
192
  change = self.__changed
131
193
  self.__changed = False
132
194
  return change
133
195
 
196
+ @property
197
+ def cache_id(self) -> str:
198
+ """A unique ID for this resolver instance, used for caching."""
199
+ if self.__cacheid is None:
200
+ hash_obj = hashlib.sha1()
201
+ hash_obj.update(self.__source.encode())
202
+ if self.__reference:
203
+ hash_obj.update(self.__reference.encode())
204
+ else:
205
+ hash_obj.update("".encode())
206
+
207
+ self.__cacheid = hash_obj.hexdigest()
208
+ return self.__cacheid
209
+
134
210
  def set_changed(self):
211
+ """Marks the resolved data as having been changed."""
135
212
  self.__changed = True
136
213
 
137
- def set_cache(self, cache):
138
- self.__cache = cache
139
-
140
214
  def resolve(self):
215
+ """
216
+ Abstract method to perform the actual data resolution.
217
+
218
+ Subclasses must implement this method to locate or fetch the data
219
+ and return its local path.
220
+ """
141
221
  raise NotImplementedError("child class must implement this")
142
222
 
223
+ @staticmethod
224
+ def __get_root_id(root):
225
+ """Generates or retrieves a unique ID for a root object."""
226
+ STORAGE = "__Resolver_cache_id"
227
+ if not getattr(root, STORAGE, None):
228
+ setattr(root, STORAGE, uuid.uuid4().hex)
229
+ return getattr(root, STORAGE)
230
+
231
+ @staticmethod
232
+ def get_cache(root, name: str = None):
233
+ """
234
+ Gets a cached path for a given root object and resolver name.
235
+
236
+ Args:
237
+ root: The root object (e.g., Chip).
238
+ name (str, optional): The name of the resolver cache to retrieve.
239
+ If None, returns a copy of the entire cache for the root.
240
+
241
+ Returns:
242
+ str or dict or None: The cached path, a copy of the cache, or None.
243
+ """
244
+ with Resolver.__CACHE_LOCK:
245
+ root_id = Resolver.__get_root_id(root)
246
+ if root_id not in Resolver.__CACHE:
247
+ Resolver.__CACHE[root_id] = {}
248
+
249
+ if name:
250
+ return Resolver.__CACHE[root_id].get(name, None)
251
+
252
+ return Resolver.__CACHE[root_id].copy()
253
+
254
+ @staticmethod
255
+ def set_cache(root, name: str, path: str):
256
+ """
257
+ Sets a cached path for a given root object and resolver name.
258
+
259
+ Args:
260
+ root: The root object (e.g., Chip).
261
+ name (str): The name of the resolver cache to set.
262
+ path (str): The path to cache.
263
+ """
264
+ with Resolver.__CACHE_LOCK:
265
+ root_id = Resolver.__get_root_id(root)
266
+ if root_id not in Resolver.__CACHE:
267
+ Resolver.__CACHE[root_id] = {}
268
+ Resolver.__CACHE[root_id][name] = path
269
+
270
+ @staticmethod
271
+ def reset_cache(root):
272
+ """
273
+ Resets the entire cache for a given root object.
274
+
275
+ Args:
276
+ root: The root object whose cache will be cleared.
277
+ """
278
+ with Resolver.__CACHE_LOCK:
279
+ root_id = Resolver.__get_root_id(root)
280
+ if root_id in Resolver.__CACHE:
281
+ del Resolver.__CACHE[root_id]
282
+
143
283
  def get_path(self):
144
- if self.name in self.__cache:
145
- return self.__cache[self.name]
284
+ """
285
+ Resolves the data source and returns its local path.
286
+
287
+ This method first checks the in-memory cache. If not found, it calls
288
+ the `resolve()` method and caches the result.
289
+
290
+ Returns:
291
+ str: The absolute path to the resolved data on the local filesystem.
292
+
293
+ Raises:
294
+ FileNotFoundError: If the resolved path does not exist.
295
+ """
296
+ cache_path = Resolver.get_cache(self.__root, self.cache_id)
297
+ if cache_path:
298
+ return cache_path
146
299
 
147
300
  path = self.resolve()
148
301
  if not os.path.exists(path):
149
- raise FileNotFoundError(f"Unable to locate {self.name} at {path}")
302
+ raise FileNotFoundError(f"Unable to locate '{self.name}' at {path}")
150
303
 
151
- if self.changed and self.name not in self.__cache:
304
+ if self.changed:
152
305
  self.logger.info(f'Saved {self.name} data to {path}')
153
306
  else:
154
307
  self.logger.info(f'Found {self.name} data at {path}')
155
- self.__cache[self.name] = path
156
- return self.__cache[self.name]
308
+
309
+ Resolver.set_cache(self.__root, self.cache_id, path)
310
+ return path
157
311
 
158
312
  def __resolve_env(self, path):
313
+ """Expands environment variables and user home directory in a path."""
159
314
  env_save = os.environ.copy()
160
315
 
161
316
  if self.root:
@@ -173,12 +328,20 @@ class Resolver:
173
328
 
174
329
 
175
330
  class RemoteResolver(Resolver):
331
+ """
332
+ An abstract base class for resolvers that fetch data from remote sources.
333
+
334
+ This class extends `Resolver` with functionality for managing a persistent
335
+ on-disk cache in `~/.sc/cache` or a user-defined location. It implements
336
+ both thread-safe and process-safe locking to prevent race conditions when
337
+ multiple SC instances try to download the same resource simultaneously.
338
+ """
176
339
  _CACHE_LOCKS = {}
177
340
  _CACHE_LOCK = threading.Lock()
178
341
 
179
342
  def __init__(self, name, root, source, reference=None):
180
343
  if reference is None:
181
- raise ValueError(f'Reference is required for cached data: {name}')
344
+ raise ValueError(f'A reference (e.g., version, commit) is required for {name}')
182
345
 
183
346
  super().__init__(name, root, source, reference)
184
347
 
@@ -187,17 +350,32 @@ class RemoteResolver(Resolver):
187
350
 
188
351
  @property
189
352
  def timeout(self):
353
+ """The maximum time in seconds to wait for a lock."""
190
354
  return self.__max_lock_wait
191
355
 
192
356
  def set_timeout(self, value):
357
+ """Sets the maximum time in seconds to wait for a lock."""
193
358
  self.__max_lock_wait = value
194
359
 
195
360
  @staticmethod
196
361
  def determine_cache_dir(root) -> Path:
362
+ """
363
+ Determines the directory for the on-disk cache.
364
+
365
+ The location is determined by ['option', 'cachedir'] if set, otherwise
366
+ it defaults to `~/.sc/cache`.
367
+
368
+ Args:
369
+ root: The root Chip object.
370
+
371
+ Returns:
372
+ Path: The path to the cache directory.
373
+ """
197
374
  default_path = os.path.join(Path.home(), '.sc', 'cache')
198
375
  if not root:
199
376
  return Path(default_path)
200
377
 
378
+ path = None
201
379
  if root.valid('option', 'cachedir'):
202
380
  path = root.get('option', 'cachedir')
203
381
  if path:
@@ -212,14 +390,17 @@ class RemoteResolver(Resolver):
212
390
 
213
391
  @property
214
392
  def cache_dir(self) -> Path:
393
+ """The directory for the on-disk cache."""
215
394
  return RemoteResolver.determine_cache_dir(self.root)
216
395
 
217
396
  @property
218
397
  def cache_name(self) -> str:
219
- return f"{self.name}-{self.reference}"
398
+ """A unique name for the cached data directory."""
399
+ return f"{self.name}-{self.reference[0:16]}-{self.cache_id[0:16]}"
220
400
 
221
401
  @property
222
402
  def cache_path(self) -> Path:
403
+ """The full path to the cached data directory."""
223
404
  cache_dir = self.cache_dir
224
405
  if not os.path.exists(cache_dir):
225
406
  os.makedirs(cache_dir, exist_ok=True)
@@ -228,6 +409,7 @@ class RemoteResolver(Resolver):
228
409
 
229
410
  @property
230
411
  def lock_file(self) -> Path:
412
+ """The path to the file used for inter-process locking."""
231
413
  cache_dir = self.cache_dir
232
414
  if not os.path.exists(cache_dir):
233
415
  os.makedirs(cache_dir, exist_ok=True)
@@ -236,6 +418,9 @@ class RemoteResolver(Resolver):
236
418
 
237
419
  @property
238
420
  def sc_lock_file(self) -> Path:
421
+ """
422
+ The path to a secondary lock file used as a fallback mechanism.
423
+ """
239
424
  cache_dir = self.cache_dir
240
425
  if not os.path.exists(cache_dir):
241
426
  os.makedirs(cache_dir, exist_ok=True)
@@ -243,38 +428,60 @@ class RemoteResolver(Resolver):
243
428
  return self.cache_dir / f"{self.cache_name}.sc_lock"
244
429
 
245
430
  def thread_lock(self):
431
+ """Gets a threading.Lock specific to this resolver instance."""
246
432
  with RemoteResolver._CACHE_LOCK:
247
433
  if self.name not in RemoteResolver._CACHE_LOCKS:
248
434
  RemoteResolver._CACHE_LOCKS[self.name] = threading.Lock()
249
435
  return RemoteResolver._CACHE_LOCKS[self.name]
250
436
 
251
437
  @contextlib.contextmanager
252
- def lock(self):
438
+ def __thread_lock(self):
439
+ """A context manager for acquiring the thread lock with a timeout."""
253
440
  lock = self.thread_lock()
254
441
  lock_acquired = False
255
442
  try:
256
- if lock.acquire_lock(timeout=self.timeout):
257
- data_path_lock = InterProcessLock(self.lock_file)
258
- sc_data_path_lock = None
259
- try:
260
- lock_acquired = data_path_lock.acquire(timeout=self.timeout)
261
- except (OSError, RuntimeError):
262
- if not lock_acquired:
263
- sc_data_path_lock = Path(self.sc_lock_file)
264
- max_seconds = self.timeout
265
- while sc_data_path_lock.exists():
266
- if max_seconds == 0:
267
- raise RuntimeError(f'Failed to access {self.cache_path}. '
268
- f'Lock {sc_data_path_lock} still exists.')
269
- time.sleep(1)
270
- max_seconds -= 1
271
- sc_data_path_lock.touch()
272
- lock_acquired = True
273
- if lock_acquired:
274
- yield
443
+ timeout = self.timeout
444
+ while timeout > 0:
445
+ if lock.acquire_lock(timeout=1):
446
+ lock_acquired = True
447
+ break
448
+ sleep_time = random.randint(1, max(1, int(timeout / 10)))
449
+ timeout -= sleep_time + 1
450
+ time.sleep(sleep_time)
451
+ if lock_acquired:
452
+ yield
275
453
  finally:
276
454
  if lock.locked():
277
455
  lock.release()
456
+
457
+ if not lock_acquired:
458
+ raise RuntimeError(f'Failed to access {self.cache_path}. '
459
+ f'Another thread is currently holding the lock.')
460
+
461
+ @contextlib.contextmanager
462
+ def __file_lock(self):
463
+ """A context manager for acquiring the inter-process file lock."""
464
+ data_path_lock = InterProcessLock(self.lock_file)
465
+ lock_acquired = False
466
+ sc_data_path_lock = None
467
+ try:
468
+ try:
469
+ lock_acquired = data_path_lock.acquire(timeout=self.timeout)
470
+ except (OSError, RuntimeError):
471
+ if not lock_acquired:
472
+ sc_data_path_lock = Path(self.sc_lock_file)
473
+ max_seconds = self.timeout
474
+ while sc_data_path_lock.exists():
475
+ if max_seconds == 0:
476
+ raise RuntimeError(f'Failed to access {self.cache_path}. '
477
+ f'Lock {sc_data_path_lock} still exists.')
478
+ time.sleep(1)
479
+ max_seconds -= 1
480
+ sc_data_path_lock.touch()
481
+ lock_acquired = True
482
+ if lock_acquired:
483
+ yield
484
+ finally:
278
485
  if lock_acquired:
279
486
  if data_path_lock.acquired:
280
487
  data_path_lock.release()
@@ -283,24 +490,54 @@ class RemoteResolver(Resolver):
283
490
 
284
491
  if not lock_acquired:
285
492
  raise RuntimeError(f'Failed to access {self.cache_path}. '
286
- f'{self.lock_file} is still locked, if this is a mistake, '
287
- 'please delete it.')
493
+ f'{self.lock_file} is still locked. If this is a mistake, '
494
+ 'please delete the lock file.')
495
+
496
+ @contextlib.contextmanager
497
+ def lock(self):
498
+ """
499
+ A context manager that acquires both the thread and file locks.
500
+
501
+ This ensures that only one thread in one process can access the cache
502
+ for a specific resource at a time.
503
+ """
504
+ with self.__thread_lock():
505
+ with self.__file_lock():
506
+ yield
288
507
 
289
508
  def resolve_remote(self):
509
+ """Abstract method to fetch the remote data."""
290
510
  raise NotImplementedError("child class must implement this")
291
511
 
292
512
  def check_cache(self):
513
+ """
514
+ Abstract method to check if the on-disk cache is valid.
515
+
516
+ Returns:
517
+ bool: True if the cache is valid, False otherwise.
518
+ """
293
519
  raise NotImplementedError("child class must implement this")
294
520
 
295
521
  def resolve(self) -> Path:
522
+ """
523
+ Resolves the remote data, using the on-disk cache if possible.
524
+
525
+ This method acquires locks, checks the cache validity, and calls
526
+ `resolve_remote()` to fetch the data if the cache is missing or invalid.
527
+
528
+ Returns:
529
+ Path: The path to the locally cached data.
530
+ """
296
531
  cache_dir = self.cache_dir
297
532
  if not os.path.exists(cache_dir):
298
533
  try:
299
534
  os.makedirs(cache_dir, exist_ok=True)
300
535
  except OSError:
536
+ # Can't create directory, return path and let it fail later
301
537
  return self.cache_path
302
538
 
303
539
  if not os.access(self.cache_dir, os.W_OK):
540
+ # Can't write to directory, assume cache is valid if it exists
304
541
  return self.cache_path
305
542
 
306
543
  with self.lock():
@@ -312,8 +549,14 @@ class RemoteResolver(Resolver):
312
549
  return self.cache_path
313
550
 
314
551
 
315
- ###############
316
552
  class FileResolver(Resolver):
553
+ """
554
+ A resolver for local file system paths.
555
+
556
+ It handles both absolute paths and paths relative to the chip's CWD.
557
+ It normalizes the source string to a `file://` URI.
558
+ """
559
+
317
560
  def __init__(self, name, root, source, reference=None):
318
561
  if source.startswith("file://"):
319
562
  source = source[7:]
@@ -324,30 +567,43 @@ class FileResolver(Resolver):
324
567
 
325
568
  @property
326
569
  def urlpath(self):
327
- parse = self.urlparse
328
- if parse.netloc:
329
- return parse.netloc
330
- else:
331
- return parse.path
570
+ """The absolute file path, stripped of the 'file://' prefix."""
571
+ # Rebuild URL and remove scheme prefix
572
+ return self.urlparse.geturl()[7:]
332
573
 
333
574
  def resolve(self):
575
+ """Returns the absolute path to the file."""
334
576
  return os.path.abspath(self.urlpath)
335
577
 
336
578
 
337
579
  class PythonPathResolver(Resolver):
580
+ """
581
+ A resolver for locating installed Python packages.
582
+
583
+ This resolver uses Python's import machinery to find the installation
584
+ directory of a given Python module. It also includes helper methods to
585
+ determine if a package is installed in "editable" mode.
586
+ """
587
+
338
588
  def __init__(self, name, root, source, reference=None):
339
589
  super().__init__(name, root, source, None)
340
590
 
341
591
  @staticmethod
342
592
  @functools.lru_cache(maxsize=1)
343
593
  def get_python_module_mapping():
594
+ """
595
+ Creates a mapping from importable module names to their distribution names.
596
+
597
+ This is used to find the distribution package that provides a given module.
598
+
599
+ Returns:
600
+ dict: A dictionary mapping module names to a list of distribution names.
601
+ """
344
602
  mapping = {}
345
603
 
346
604
  for dist in distributions():
347
- dist_name = None
348
- if hasattr(dist, 'name'):
349
- dist_name = dist.name
350
- else:
605
+ dist_name = getattr(dist, 'name', None)
606
+ if not dist_name:
351
607
  metadata = dist.read_text('METADATA')
352
608
  if metadata:
353
609
  find_name = re.compile(r'Name: (.*)')
@@ -369,15 +625,30 @@ class PythonPathResolver(Resolver):
369
625
 
370
626
  @staticmethod
371
627
  def is_python_module_editable(module_name):
628
+ """
629
+ Checks if a Python module is installed in "editable" mode.
630
+
631
+ Args:
632
+ module_name (str): The name of the Python module to check.
633
+
634
+ Returns:
635
+ bool: True if the module is installed in editable mode, False otherwise.
636
+ """
372
637
  dist_map = PythonPathResolver.get_python_module_mapping()
373
- dist = dist_map[module_name][0]
638
+ if module_name not in dist_map:
639
+ return False
640
+ dist_name = dist_map[module_name][0]
374
641
 
375
642
  is_editable = False
376
- for f in distribution(dist).files:
643
+ dist_obj = distribution(dist_name)
644
+ if not dist_obj or not dist_obj.files:
645
+ return False
646
+
647
+ for f in dist_obj.files:
377
648
  if f.name == 'direct_url.json':
378
649
  info = None
379
- with open(f.locate(), 'r') as f:
380
- info = json.load(f)
650
+ with open(f.locate(), 'r') as fp:
651
+ info = json.load(fp)
381
652
 
382
653
  if "dir_info" in info:
383
654
  is_editable = info["dir_info"].get("editable", False)
@@ -391,40 +662,93 @@ class PythonPathResolver(Resolver):
391
662
  alternative_path,
392
663
  alternative_ref=None,
393
664
  python_module_path_append=None):
394
- '''
395
- Helper function to register a python module as data source with an alternative in case
396
- the module is not installed in an editable state
397
- '''
665
+ """
666
+ Helper to conditionally register a Python module or a fallback path.
667
+
668
+ If the specified `python_module` is installed in editable mode, it's
669
+ registered as the source. Otherwise, the `alternative_path` and
670
+ `alternative_ref` are used.
671
+
672
+ Args:
673
+ root (Chip): The chip object to register the source with.
674
+ package_name (str): The name of the package to register.
675
+ python_module (str): The Python module to check for.
676
+ alternative_path (str): The fallback source path.
677
+ alternative_ref (str, optional): The fallback reference. Defaults to None.
678
+ python_module_path_append (str, optional): A subdirectory to append
679
+ to the resolved Python module path. Defaults to None.
680
+ """
681
+ if PythonPathResolver.is_python_module_editable(python_module):
682
+ path = f"python://{python_module}"
683
+ if python_module_path_append:
684
+ py_path = PythonPathResolver(python_module, root, path).resolve()
685
+ path = os.path.abspath(os.path.join(py_path, python_module_path_append))
686
+ ref = None
687
+ else:
688
+ path = alternative_path
689
+ ref = alternative_ref
690
+
691
+ root.register_source(name=package_name, path=path, ref=ref)
692
+
693
+ @staticmethod
694
+ def set_dataroot(root,
695
+ package_name,
696
+ python_module,
697
+ alternative_path,
698
+ alternative_ref=None,
699
+ python_module_path_append=None):
700
+ """
701
+ DEPRECATED: Use register_source.
702
+ Helper to conditionally set a dataroot to a Python module or a fallback path.
703
+ """
398
704
  # check if installed in an editable state
399
705
  if PythonPathResolver.is_python_module_editable(python_module):
706
+ path = f"python://{python_module}"
400
707
  if python_module_path_append:
401
- path = PythonPathResolver(
402
- python_module, root, f"python://{python_module}").resolve()
403
- path = os.path.abspath(os.path.join(path, python_module_path_append))
404
- else:
405
- path = f"python://{python_module}"
708
+ py_path = PythonPathResolver(python_module, root, path).resolve()
709
+ path = os.path.abspath(os.path.join(py_path, python_module_path_append))
406
710
  ref = None
407
711
  else:
408
712
  path = alternative_path
409
713
  ref = alternative_ref
410
714
 
411
- root.register_source(name=package_name,
412
- path=path,
413
- ref=ref)
715
+ root.set_dataroot(package_name, path=path, tag=ref)
414
716
 
415
717
  def resolve(self):
718
+ """
719
+ Resolves the path to the specified Python module.
720
+
721
+ Returns:
722
+ str: The absolute path to the module's directory.
723
+ """
416
724
  module = importlib.import_module(self.urlpath)
417
725
  python_path = os.path.dirname(module.__file__)
418
726
  return os.path.abspath(python_path)
419
727
 
420
728
 
421
729
  class KeyPathResolver(Resolver):
730
+ """
731
+ A resolver for finding file paths stored within the Chip schema itself.
732
+
733
+ This resolver takes a keypath (e.g., 'tool,openroad,exe') and uses the
734
+ `find_files` method of the root Chip object to locate the corresponding file.
735
+ """
736
+
422
737
  def __init__(self, name, root, source, reference=None):
423
738
  super().__init__(name, root, source, None)
424
739
 
425
740
  def resolve(self):
741
+ """
742
+ Resolves the path by looking up the keypath in the Chip schema.
743
+
744
+ Returns:
745
+ str: The file path found in the schema.
746
+
747
+ Raises:
748
+ RuntimeError: If the resolver does not have a root Chip object defined.
749
+ """
426
750
  if not self.root:
427
- raise RuntimeError(f"Root schema has not be defined for {self.name}")
751
+ raise RuntimeError(f"A root schema has not been defined for '{self.name}'")
428
752
 
429
753
  key = self.urlpath.split(",")
430
754
  if self.root.get(*key, field='pernode').is_never():