librelane 2.4.0.dev2__py3-none-any.whl → 2.4.7__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 (55) hide show
  1. librelane/__init__.py +1 -1
  2. librelane/__main__.py +34 -27
  3. librelane/common/__init__.py +2 -0
  4. librelane/common/cli.py +1 -1
  5. librelane/common/drc.py +1 -0
  6. librelane/common/generic_dict.py +1 -1
  7. librelane/common/metrics/__main__.py +1 -1
  8. librelane/common/misc.py +58 -2
  9. librelane/common/tcl.py +2 -1
  10. librelane/common/types.py +2 -3
  11. librelane/config/__main__.py +1 -4
  12. librelane/config/flow.py +2 -2
  13. librelane/config/preprocessor.py +1 -1
  14. librelane/config/variable.py +136 -7
  15. librelane/container.py +55 -31
  16. librelane/env_info.py +129 -115
  17. librelane/examples/hold_eco_demo/config.yaml +18 -0
  18. librelane/examples/hold_eco_demo/demo.v +27 -0
  19. librelane/flows/cli.py +39 -23
  20. librelane/flows/flow.py +100 -36
  21. librelane/help/__main__.py +39 -0
  22. librelane/scripts/magic/def/mag_gds.tcl +0 -2
  23. librelane/scripts/magic/drc.tcl +0 -1
  24. librelane/scripts/magic/gds/extras_mag.tcl +0 -2
  25. librelane/scripts/magic/gds/mag_with_pointers.tcl +0 -1
  26. librelane/scripts/magic/lef/extras_maglef.tcl +0 -2
  27. librelane/scripts/magic/lef/maglef.tcl +0 -1
  28. librelane/scripts/magic/wrapper.tcl +2 -0
  29. librelane/scripts/odbpy/defutil.py +15 -10
  30. librelane/scripts/odbpy/eco_buffer.py +182 -0
  31. librelane/scripts/odbpy/eco_diode.py +140 -0
  32. librelane/scripts/odbpy/ioplace_parser/__init__.py +1 -1
  33. librelane/scripts/odbpy/ioplace_parser/parse.py +1 -1
  34. librelane/scripts/odbpy/power_utils.py +8 -6
  35. librelane/scripts/odbpy/reader.py +17 -13
  36. librelane/scripts/openroad/common/io.tcl +66 -2
  37. librelane/scripts/openroad/gui.tcl +23 -1
  38. librelane/state/design_format.py +16 -1
  39. librelane/state/state.py +11 -3
  40. librelane/steps/__init__.py +1 -1
  41. librelane/steps/__main__.py +4 -4
  42. librelane/steps/checker.py +7 -8
  43. librelane/steps/klayout.py +11 -1
  44. librelane/steps/magic.py +24 -14
  45. librelane/steps/misc.py +5 -0
  46. librelane/steps/odb.py +193 -28
  47. librelane/steps/openroad.py +64 -47
  48. librelane/steps/pyosys.py +18 -1
  49. librelane/steps/step.py +36 -17
  50. librelane/steps/yosys.py +9 -1
  51. {librelane-2.4.0.dev2.dist-info → librelane-2.4.7.dist-info}/METADATA +10 -11
  52. {librelane-2.4.0.dev2.dist-info → librelane-2.4.7.dist-info}/RECORD +54 -50
  53. {librelane-2.4.0.dev2.dist-info → librelane-2.4.7.dist-info}/entry_points.txt +1 -0
  54. librelane/scripts/odbpy/exception_codes.py +0 -17
  55. {librelane-2.4.0.dev2.dist-info → librelane-2.4.7.dist-info}/WHEEL +0 -0
librelane/__init__.py CHANGED
@@ -13,7 +13,7 @@
13
13
  # limitations under the License.
14
14
  """
15
15
  The LibreLane API
16
- ----------------
16
+ -----------------
17
17
 
18
18
  Documented elements of this API represent the primary programming interface for
19
19
  the LibreLane infrastructure.
librelane/__main__.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2025 The American University in Cairo
1
+ # Copyright 2025 LibreLane Contributors
2
2
  #
3
3
  # Adapted from OpenLane
4
4
  #
@@ -22,7 +22,6 @@ import shutil
22
22
  import marshal
23
23
  import tempfile
24
24
  import traceback
25
- import subprocess
26
25
  from textwrap import dedent
27
26
  from functools import partial
28
27
  from typing import Any, Dict, Sequence, Tuple, Type, Optional, List
@@ -221,7 +220,10 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool):
221
220
  if len(discovered_plugins) > 0:
222
221
  print("Discovered plugins:")
223
222
  for name, module in discovered_plugins.items():
224
- print(f"{name} -> {module.__version__}")
223
+ if hasattr(module, "__version__"):
224
+ print(f"{name} -> {module.__version__}")
225
+ else:
226
+ print(f"{name}")
225
227
 
226
228
  ctx.exit(0)
227
229
 
@@ -278,20 +280,12 @@ def run_included_example(
278
280
  if os.path.isdir(final_path):
279
281
  print(f"A directory named {value} already exists.", file=sys.stderr)
280
282
  ctx.exit(1)
281
- # 1. Copy the files
282
- shutil.copytree(
283
- example_path,
284
- final_path,
285
- symlinks=False,
286
- )
287
-
288
- # 2. Make files writable
289
- if os.name == "posix":
290
- subprocess.check_call(["chmod", "-R", "755", final_path])
291
283
 
284
+ # 1. Copy the files
285
+ common.recreate_tree(example_path, final_path)
292
286
  config_file = glob.glob(os.path.join(final_path, "config.*"))[0]
293
287
 
294
- # 3. Run
288
+ # 2. Run
295
289
  run(
296
290
  ctx,
297
291
  config_files=[config_file],
@@ -321,28 +315,38 @@ def cli_in_container(
321
315
  if not value:
322
316
  return
323
317
 
324
- docker_mounts = list(ctx.params.get("docker_mounts") or ())
325
- docker_tty: bool = ctx.params.get("docker_tty", True)
318
+ mounts = list(ctx.params.get("docker_mounts") or ())
319
+ tty: bool = ctx.params.get("docker_tty", True)
326
320
  pdk_root = ctx.params.get("pdk_root")
327
- argv = sys.argv[sys.argv.index("--dockerized") + 1 :]
321
+
322
+ try:
323
+ containerized_index = sys.argv.index("--dockerized")
324
+ except ValueError:
325
+ containerized_index = sys.argv.index("--containerized")
326
+
327
+ argv = sys.argv[containerized_index + 1 :]
328
328
 
329
329
  final_argv = ["zsh"]
330
330
  if len(argv) != 0:
331
- final_argv = ["librelane"] + argv
331
+ final_argv = ["python3", "-m", "librelane"] + argv
332
332
 
333
- docker_image = os.getenv(
333
+ container_image = os.getenv(
334
334
  "LIBRELANE_IMAGE_OVERRIDE", f"ghcr.io/librelane/librelane:{__version__}"
335
335
  )
336
336
 
337
337
  try:
338
338
  run_in_container(
339
- docker_image,
339
+ container_image,
340
340
  final_argv,
341
341
  pdk_root=pdk_root,
342
- other_mounts=docker_mounts,
343
- tty=docker_tty,
342
+ other_mounts=mounts,
343
+ tty=tty,
344
344
  )
345
+ except ValueError as e:
346
+ err(e)
347
+ ctx.exit(1)
345
348
  except Exception as e:
349
+ traceback.print_exc(file=sys.stderr)
346
350
  err(e)
347
351
  ctx.exit(1)
348
352
 
@@ -377,25 +381,28 @@ o = partial(option, show_default=True)
377
381
  "Containerization options",
378
382
  o(
379
383
  "--docker-mount",
384
+ "--container-mount",
380
385
  "-m",
381
386
  "docker_mounts",
382
387
  multiple=True,
383
- is_eager=True, # docker options should be processed before anything else
388
+ is_eager=True, # container options should be processed before anything else
384
389
  default=(),
385
- help="Used to mount more directories in dockerized mode. If a valid directory is specified, it will be mounted in the same path in the container. Otherwise, the value of the option will be passed to the Docker-compatible container engine verbatim. Must be passed before --dockerized, has no effect if --dockerized is not set.",
390
+ help="Used to mount more directories in dockerized mode. If a valid directory is specified, it will be mounted in the same path in the container. Otherwise, the value of the option will be passed to the container engine verbatim. Must be passed before --containerized/--dockerized, has no effect if not set.",
386
391
  ),
387
392
  o(
388
393
  "--docker-tty/--docker-no-tty",
389
- is_eager=True, # docker options should be processed before anything else
394
+ "--container-tty/--container-no-tty",
395
+ is_eager=True, # container options should be processed before anything else
390
396
  default=True,
391
- help="Controls the allocation of a virtual terminal by passing -t to the Docker-compatible container engine invocation. Must be passed before --dockerized, has no effect if --dockerized is not set.",
397
+ help="Controls the allocation of a virtual terminal by passing -t to the Docker-compatible container engine invocation. Must be passed before --containerized/--dockerized, has no effect if not set.",
392
398
  ),
393
399
  o(
394
400
  "--dockerized",
401
+ "--containerized",
395
402
  default=False,
396
403
  is_flag=True,
397
404
  is_eager=True, # docker options should be processed before anything else
398
- help="Run the remaining flags using a Docker container. Some caveats apply. Must precede all options except --docker-mount, --docker-tty/--docker-no-tty.",
405
+ help="Run the remaining flags using a containerized version of LibreLane. Some caveats apply. Must precede all options except --{docker,container}-mount, --{docker,container}-[no-]tty.",
399
406
  callback=cli_in_container,
400
407
  ),
401
408
  )
@@ -41,8 +41,10 @@ from .misc import (
41
41
  format_size,
42
42
  format_elapsed_time,
43
43
  Filter,
44
+ recreate_tree,
44
45
  get_latest_file,
45
46
  process_list_file,
47
+ count_occurences,
46
48
  _get_process_limit,
47
49
  )
48
50
  from .types import (
librelane/common/cli.py CHANGED
@@ -67,7 +67,7 @@ class IntEnumChoice(Choice):
67
67
  f"{value} is not a not a valid value for IntEnum {self.__enum.__name__}"
68
68
  )
69
69
 
70
- def get_metavar(self, param: "Parameter") -> str:
70
+ def get_metavar(self, param: Parameter) -> str:
71
71
  _bk = self.choices
72
72
  self.choices = [f"{e.name} or {e.value}" for e in self.__enum]
73
73
  result = super().get_metavar(param)
librelane/common/drc.py CHANGED
@@ -208,6 +208,7 @@ class DRC:
208
208
  from lxml import etree as ET
209
209
 
210
210
  with ET.xmlfile(out, encoding="utf8", buffered=False) as xf:
211
+ xf.write_declaration()
211
212
  with xf.element("report-database"):
212
213
  # 1. Cells
213
214
  with xf.element("cells"):
@@ -69,7 +69,7 @@ VT = TypeVar("VT")
69
69
 
70
70
  class GenericDict(Mapping[KT, VT]):
71
71
  """
72
- A dictionary with generic keys and values that is compatible with Python 3.8.
72
+ A dictionary with generic keys and values that is compatible with Python 3.8.1.
73
73
 
74
74
  :param copying: A base Mapping object to copy values from.
75
75
  :param overrides: Another mapping object to override the value from `copying`
@@ -286,7 +286,7 @@ cli.add_command(compare_multiple)
286
286
  @cloup.option(
287
287
  "-m",
288
288
  "--metric-repo",
289
- default="efabless/librelane-metrics",
289
+ default="librelane/librelane-metrics",
290
290
  help="The repository storing metrics for --repo",
291
291
  )
292
292
  @cloup.option(
librelane/common/misc.py CHANGED
@@ -11,16 +11,19 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
+ import io
14
15
  import os
15
16
  import re
16
17
  import glob
17
18
  import gzip
19
+ import shutil
18
20
  import typing
19
21
  import fnmatch
20
22
  import pathlib
21
23
  import unicodedata
22
24
  from math import inf
23
25
  from typing import (
26
+ IO,
24
27
  Any,
25
28
  Generator,
26
29
  Iterable,
@@ -109,6 +112,8 @@ def get_opdks_rev() -> str:
109
112
  # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
110
113
  def slugify(value: str, lower: bool = False) -> str:
111
114
  """
115
+ Adapted from Django slugify. In practice it works more like a kebabify…
116
+
112
117
  :param value: Input string
113
118
  :returns: The input string converted to lower case, with all characters
114
119
  except alphanumerics, underscores and hyphens removed, and spaces and\
@@ -314,6 +319,37 @@ class Filter(object):
314
319
  yield input
315
320
 
316
321
 
322
+ def recreate_tree(
323
+ source: AnyPath,
324
+ target: AnyPath,
325
+ ):
326
+ """
327
+ This function attempts to recreate a file tree from a source path in another
328
+ target path.
329
+
330
+ Permissions are not copied over. Symlinks and hardlinks are followed.
331
+
332
+ Directories are not recreated unless they contain files as (grand)children.
333
+
334
+ If the source and target are the same, the function returns early and does
335
+ nothing.
336
+
337
+ :param source: The source file tree to replicate
338
+ :param target: The target path to recreate the file tree within
339
+ """
340
+ source = os.path.abspath(source)
341
+ target = os.path.abspath(target)
342
+ if os.path.exists(target) and os.path.samefile(source, target):
343
+ return
344
+ for dirname, _, files in os.walk(source):
345
+ for file in files:
346
+ resolved = os.path.join(dirname, file)
347
+ resolved_target = os.path.join(target, os.path.relpath(resolved, source))
348
+ os.makedirs(os.path.dirname(resolved_target), exist_ok=True)
349
+ with open(resolved, "rb") as fi, open(resolved_target, "wb") as fo:
350
+ shutil.copyfileobj(fi, fo)
351
+
352
+
317
353
  def get_latest_file(in_path: Union[str, os.PathLike], filename: str) -> Optional[Path]:
318
354
  """
319
355
  :param in_path: A directory to search in
@@ -378,9 +414,9 @@ def _get_process_limit() -> int:
378
414
  return int(os.getenv("_OPENLANE_MAX_CORES", os.cpu_count() or 1))
379
415
 
380
416
 
381
- def gzopen(filename, mode="rt"):
417
+ def gzopen(filename: AnyPath, mode="rt") -> IO[Any]:
382
418
  """
383
- This method (tries to?) emulate the gzopen from the Linux Standard Base,
419
+ This function (tries to?) emulate the gzopen from the Linux Standard Base,
384
420
  specifically this part:
385
421
 
386
422
  If path refers to an uncompressed file, and mode refers to a read mode,
@@ -388,6 +424,11 @@ def gzopen(filename, mode="rt"):
388
424
  for reading directly from the file without any decompression.
389
425
 
390
426
  gzip.open does not have this behavior.
427
+
428
+ :param filename: The full path to the uncompressed or gzipped file.
429
+ :param mode: "r", "rb", "w", "wb", "x", "xb", "a" or "ab" for
430
+ binary mode, or "rt", "wt", "xt" or "at" for text mode.
431
+ :returns: An I/O wrapper that may very slightly based on the mode.
391
432
  """
392
433
  try:
393
434
  g = gzip.open(filename, mode=mode)
@@ -400,3 +441,18 @@ def gzopen(filename, mode="rt"):
400
441
  except gzip.BadGzipFile:
401
442
  g.close()
402
443
  return open(filename, mode=mode)
444
+
445
+
446
+ def count_occurences(fp: io.TextIOWrapper, pattern: str = "") -> int:
447
+ """
448
+ Counts the occurences of a certain string in a stream, line-by-line, without
449
+ necessarily loading the entire file into memory.
450
+
451
+ Equivalent to: ``grep -c 'pattern' <file>`` (but without regex support).
452
+
453
+ :param fp: the text stream
454
+ :param pattern: the substring to search for. if set to "", it will simply
455
+ count the lines in the file.
456
+ :returns: the number of matching lines
457
+ """
458
+ return sum(pattern in line for line in fp)
librelane/common/tcl.py CHANGED
@@ -12,7 +12,6 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
  import re
15
- import tkinter
16
15
  from typing import Dict, Mapping, Any, Iterable
17
16
 
18
17
  _env_rx = re.compile(r"(?:\:\:)?env\((\w+)\)")
@@ -55,6 +54,8 @@ class TclUtils(object):
55
54
 
56
55
  @staticmethod
57
56
  def _eval_env(env_in: Mapping[str, Any], tcl_in: str) -> Dict[str, Any]:
57
+ import tkinter
58
+
58
59
  interpreter = tkinter.Tcl()
59
60
 
60
61
  interpreter.eval("array unset ::env")
librelane/common/types.py CHANGED
@@ -16,6 +16,7 @@ import sys
16
16
  import tempfile
17
17
  from math import isfinite
18
18
  from decimal import Decimal
19
+ from weakref import finalize
19
20
  from collections import UserString
20
21
  from typing import Any, Union, ClassVar, Tuple, Optional
21
22
 
@@ -112,6 +113,4 @@ class ScopedFile(Path):
112
113
  super().__init__(self._ntf.name)
113
114
  self._ntf.write(contents)
114
115
  self._ntf.close()
115
-
116
- def __del__(self):
117
- os.unlink(self._ntf.name)
116
+ self._ntf_cleanup = finalize(self, os.unlink, self._ntf.name)
@@ -14,7 +14,6 @@
14
14
  import os
15
15
  import sys
16
16
  import json
17
- import functools
18
17
  from decimal import Decimal
19
18
 
20
19
  import click
@@ -111,9 +110,7 @@ def create_config(
111
110
  print("At least one source RTL file is required.", file=sys.stderr)
112
111
  exit(1)
113
112
  source_rtl_key = "VERILOG_FILES"
114
- if not functools.reduce(
115
- lambda acc, x: acc and (x.endswith(".sv") or x.endswith(".v")), source_rtl, True
116
- ):
113
+ if not all(file.endswith(".sv") or file.endswith(".v") for file in source_rtl):
117
114
  print(
118
115
  "Only Verilog/SystemVerilog files are supported by create-config.",
119
116
  file=sys.stderr,
librelane/config/flow.py CHANGED
@@ -153,13 +153,13 @@ pdk_variables = [
153
153
  Variable(
154
154
  "FP_IO_HLAYER",
155
155
  str,
156
- "The metal layer on which to place horizontal IO pins, i.e., the top and bottom of the die.",
156
+ "The metal layer on which to place horizontally-aligned (long side parallel with the horizon) pins alongside the east and west edges of the die.",
157
157
  pdk=True,
158
158
  ),
159
159
  Variable(
160
160
  "FP_IO_VLAYER",
161
161
  str,
162
- "The metal layer on which to place vertical IO pins, i.e., the top and bottom of the die.",
162
+ "The metal layer on which to place vertically-aligned (long side perpendicular to the horizon) pins alongside the north and south edges of the die.",
163
163
  pdk=True,
164
164
  ),
165
165
  Variable("RT_MIN_LAYER", str, "The lowest metal layer to route on.", pdk=True),
@@ -190,7 +190,7 @@ class Expr(object):
190
190
  elif token.value == "+":
191
191
  result = number1 + number2
192
192
  elif token.value == "-":
193
- result = number1 + number2
193
+ result = number1 - number2
194
194
 
195
195
  eval_stack.append(result)
196
196
  except IndexError:
@@ -24,9 +24,11 @@ from dataclasses import (
24
24
  fields,
25
25
  is_dataclass,
26
26
  )
27
+ import textwrap
27
28
  from typing import (
28
29
  ClassVar,
29
30
  Dict,
31
+ Iterable,
30
32
  List,
31
33
  Literal,
32
34
  Optional,
@@ -238,7 +240,7 @@ def some_of(t: Type[Any]) -> Type[Any]:
238
240
  return new_union # type: ignore
239
241
 
240
242
 
241
- def repr_type(t: Type[Any]) -> str: # pragma: no cover
243
+ def repr_type(t: Type[Any], for_document: bool = False) -> str: # pragma: no cover
242
244
  optional = is_optional(t)
243
245
  some = some_of(t)
244
246
 
@@ -247,18 +249,25 @@ def repr_type(t: Type[Any]) -> str: # pragma: no cover
247
249
  else:
248
250
  type_string = str(some)
249
251
 
252
+ if is_dataclass(t):
253
+ type_string = (
254
+ f"{{class}}`{some.__qualname__} <{some.__module__}.{some.__qualname__}>`"
255
+ )
256
+
257
+ separator = "|<br />" if for_document else "|"
258
+
250
259
  if inspect.isclass(some) and issubclass(some, Enum):
251
- type_string = "|".join([str(e.name) for e in some])
260
+ type_string = separator.join([str(e.name) for e in some])
252
261
  type_string = f"`{type_string}`"
253
262
  else:
254
263
  origin, args = get_origin(some), get_args(some)
255
264
  if origin is not None:
256
265
  if origin == Union:
257
266
  arg_strings = [repr_type(arg) for arg in args]
258
- type_string = "|".join(arg_strings)
267
+ type_string = separator.join(arg_strings)
259
268
  type_string = f"({type_string})"
260
269
  elif origin == Literal:
261
- return "|".join([repr(arg) for arg in args])
270
+ return separator.join([repr(arg) for arg in args])
262
271
  else:
263
272
  arg_strings = [repr_type(arg) for arg in args]
264
273
  type_string = f"{type_string}[{', '.join(arg_strings)}]"
@@ -377,9 +386,7 @@ class Variable:
377
386
  for easier wrapping by web browsers/PDF renderers/what have you
378
387
  :returns: A pretty Markdown string representation of the Variable's type.
379
388
  """
380
- if for_document:
381
- return repr_type(self.type).replace("|", "|<br />")
382
- return repr_type(self.type)
389
+ return repr_type(self.type, for_document=for_document)
383
390
 
384
391
  def desc_repr_md(self) -> str: # pragma: no cover
385
392
  """
@@ -387,6 +394,112 @@ class Variable:
387
394
  """
388
395
  return self.description.replace("\n", "<br />")
389
396
 
397
+ @staticmethod
398
+ def _render_table_md(
399
+ vars: Iterable["Variable"],
400
+ *,
401
+ myst_anchor_owner_id: Optional[str] = None,
402
+ ) -> str: # pragma; no cover
403
+ """
404
+ Renders a markdown table for any iterable of configuration variables.
405
+
406
+ :param vars: Any iterable object returning configuration variables
407
+ :param myst_anchor_owner_id:
408
+ If set, the table is rendered for MyST using a mix of HTML and
409
+ Markdown, with anchors and a detail tag containing deprecated names.
410
+
411
+ For universal flow variables, set the anchor id to "".
412
+ :returns: A markdown string representing the table
413
+ """
414
+ include_units = any(c.units is not None for c in vars)
415
+ if myst_anchor_owner_id is None:
416
+ # Markdown mode
417
+ result = textwrap.dedent(
418
+ f"""
419
+ | Variable Name | Type | Description | Default | {'Units |' * include_units}
420
+ | - | - | - | - | {'- |' * include_units}
421
+ """
422
+ )
423
+ for var in vars:
424
+ units = var.units or ""
425
+ pdk_superscript = "<sup>PDK</sup>" if var.pdk else ""
426
+ result += f"| `{var.name}` {pdk_superscript} | {var.type_repr_md(for_document=True)} | {var.desc_repr_md()} | `{var.default}` |"
427
+ if include_units:
428
+ result += f" {units} |"
429
+ result += "\n"
430
+ result += "\n"
431
+ else:
432
+ if myst_anchor_owner_id == "":
433
+ # for _get_docs_identifier, where None is the behavior we want
434
+ # for a literal ""
435
+ myst_anchor_owner_id = None
436
+ result = textwrap.dedent(
437
+ f"""
438
+ <div class="table-wrapper colwidths-auto docutils container">
439
+ <table class="docutils align-default">
440
+ <thead><tr>
441
+ <th class="head">Variable Name</th>
442
+ <th class="head">Type</th>
443
+ <th class="head">Description</th>
444
+ <th class="head">Default</th>
445
+ {'<th class="head">Units</th>' * include_units}
446
+ </tr></thead>
447
+ <tbody>
448
+ """
449
+ )
450
+ for var in vars:
451
+ units = var.units or ""
452
+ pdk_superscript = "<sup>PDK</sup>" if var.pdk else ""
453
+ var_anchor = f"{{#{var._get_docs_identifier(myst_anchor_owner_id)}}}"
454
+
455
+ result += textwrap.dedent(
456
+ f"""
457
+ <tr>
458
+ <td>
459
+
460
+ `{var.name}`{var_anchor} {pdk_superscript}
461
+ """
462
+ )
463
+ if len(var.deprecated_names):
464
+ result += "<details><summary>Deprecated names</summary>\n\n"
465
+ for dn in var.deprecated_names:
466
+ if isinstance(dn, tuple):
467
+ dn = dn[0]
468
+ result += f"* `{dn}`\n"
469
+ result += "\n</details>\n"
470
+ result += textwrap.dedent(
471
+ f"""
472
+ </td>
473
+ <td>
474
+
475
+ {var.type_repr_md(for_document=True)}
476
+
477
+ </td>
478
+ <td>
479
+
480
+ {var.desc_repr_md()}
481
+
482
+ </td>
483
+ <td>
484
+
485
+ `{var.default}`
486
+
487
+ </td>
488
+ """
489
+ )
490
+ result += include_units * textwrap.dedent(
491
+ f"""
492
+ <td>
493
+
494
+ {units}
495
+
496
+ </td>
497
+ """
498
+ )
499
+ result += "\n</tr>"
500
+ result += "</tbody></table></div>\n"
501
+ return result
502
+
390
503
  def __process(
391
504
  self,
392
505
  key_path: str,
@@ -435,6 +548,9 @@ class Variable:
435
548
  return_value = list()
436
549
  raw = value
437
550
  if isinstance(raw, list) or isinstance(raw, tuple):
551
+ if validating_type == List[Path]:
552
+ if any(isinstance(item, List) for item in raw):
553
+ Variable.__flatten_list(value)
438
554
  pass
439
555
  elif is_string(raw):
440
556
  if not permissive_typing:
@@ -720,3 +836,16 @@ class Variable:
720
836
  and self.type == rhs.type
721
837
  and self.default == rhs.default
722
838
  )
839
+
840
+ # Flatten list. Note: Must modify value, not return a new list.
841
+ @staticmethod
842
+ def __flatten_list(value: list):
843
+ new_list = []
844
+ for item in value:
845
+ if isinstance(item, list):
846
+ for sub_item in item:
847
+ new_list.append(sub_item)
848
+ else:
849
+ new_list.append(item)
850
+
851
+ value[:] = new_list