mirror of
https://github.com/TinyTapeout/tt-support-tools.git
synced 2026-11-03 04:36:09 +01:00
Sylvain: The techfile for GF180 is still a bit dodgy in some areas, especially when reading from GDS and it's rejecting perfectly valid designs. Wafer.space relies only on KLayout DRC for acceptance so we should do too for the time being.
558 lines
19 KiB
Python
558 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import traceback
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import gdstk
|
|
import klayout.db as pya
|
|
import klayout.rdb as rdb
|
|
import yaml
|
|
from klayout_tools import parse_lyp_layers
|
|
from pin_check import pin_check
|
|
from precheck_failure import PrecheckFailure
|
|
from tech_data import (
|
|
analog_pin_rects,
|
|
boundary_layer,
|
|
forbidden_layers,
|
|
lyp_filename,
|
|
tech_names,
|
|
valid_layers,
|
|
)
|
|
|
|
PDK_ROOT = os.getenv("PDK_ROOT")
|
|
PDK_NAME = os.getenv("PDK") or "sky130A"
|
|
LYP_DIR = f"{PDK_ROOT}/{PDK_NAME}/libs.tech/klayout/tech"
|
|
REPORTS_PATH = os.path.join(os.path.dirname(os.path.realpath(__file__)), "reports")
|
|
|
|
if not PDK_ROOT:
|
|
logging.error("PDK_ROOT environment variable not set")
|
|
exit(1)
|
|
|
|
|
|
def has_sky130_devices(gds: str):
|
|
for cell_name in gdstk.read_rawcells(gds):
|
|
if cell_name.startswith("sky130_fd_"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def load_layers(tech: str, only_valid: bool = True):
|
|
lyp_file = f"{LYP_DIR}/{lyp_filename[tech]}"
|
|
layers = parse_lyp_layers(lyp_file, only_valid)
|
|
return layers
|
|
|
|
|
|
def magic_drc(gds: str, toplevel: str):
|
|
logging.info(f"Running magic DRC on {gds} (module={toplevel})")
|
|
|
|
magic = subprocess.run(
|
|
[
|
|
"magic",
|
|
"-noconsole",
|
|
"-dnull",
|
|
"-rcfile",
|
|
f"{PDK_ROOT}/{PDK_NAME}/libs.tech/magic/{PDK_NAME}.magicrc",
|
|
"magic_drc.tcl",
|
|
gds,
|
|
toplevel,
|
|
PDK_ROOT,
|
|
f"{REPORTS_PATH}/magic_drc.txt",
|
|
f"{REPORTS_PATH}/magic_drc.mag",
|
|
],
|
|
)
|
|
|
|
if magic.returncode != 0:
|
|
if not has_sky130_devices(gds):
|
|
logging.warning("No sky130 devices present - was the design flattened?")
|
|
raise PrecheckFailure("Magic DRC failed")
|
|
|
|
|
|
def klayout_custom_drc(
|
|
check: str, script_path: str, script_vars: dict[str, str], report_vars: list[str]
|
|
):
|
|
report_file = f"{REPORTS_PATH}/drc_{check}.xml"
|
|
klayout_args = ["klayout", "-b", "-r", script_path]
|
|
for k, v in script_vars.items():
|
|
klayout_args.extend(["-rd", f"{k}={v}"])
|
|
for k in report_vars:
|
|
klayout_args.extend(["-rd", f"{k}={report_file}"])
|
|
klayout = subprocess.run(klayout_args)
|
|
if klayout.returncode != 0:
|
|
raise PrecheckFailure(f"Klayout {check} failed")
|
|
|
|
report = rdb.ReportDatabase("DRC")
|
|
report.load(report_file)
|
|
|
|
if report.num_items() > 0:
|
|
raise PrecheckFailure(
|
|
f"Klayout {check} failed with {report.num_items()} DRC violations"
|
|
)
|
|
|
|
|
|
def klayout_drc(
|
|
gds: str,
|
|
check: str,
|
|
script=f"{PDK_NAME}_mr.drc",
|
|
script_dir="tech-files",
|
|
extra_vars=[],
|
|
):
|
|
logging.info(f"Running klayout {check} on {gds}")
|
|
if "/" not in script:
|
|
script = f"{script_dir}/{script}"
|
|
script_vars = {
|
|
check: "true",
|
|
"input": gds,
|
|
"thr": "1", # single-threaded operation in sky130A_mr.drc to work around klayout bug
|
|
}
|
|
script_vars.update(extra_vars)
|
|
klayout_custom_drc(check, script, script_vars, ["report", "report_file"])
|
|
|
|
|
|
def klayout_zero_area(gds: str):
|
|
return klayout_drc(gds, "zero_area", "zeroarea.rb.drc")
|
|
|
|
|
|
def klayout_sg13g2(gds: str):
|
|
return klayout_drc(
|
|
gds,
|
|
"sg13g2",
|
|
f"{PDK_ROOT}/{PDK_NAME}/libs.tech/klayout/tech/drc/ihp-sg13g2.drc",
|
|
)
|
|
|
|
|
|
def klayout_gf180mcuD_antenna(gds: str):
|
|
return klayout_drc(
|
|
gds,
|
|
"antenna",
|
|
"antenna.drc",
|
|
script_dir=f"{PDK_ROOT}/{PDK_NAME}/libs.tech/klayout/tech/drc/rule_decks",
|
|
)
|
|
|
|
|
|
def klayout_checks(gds: str, expected_name: str, tech: str):
|
|
layout = pya.Layout()
|
|
layout.read(gds)
|
|
layers = load_layers(tech)
|
|
|
|
logging.info("Running top macro name check...")
|
|
top_cell = layout.top_cell()
|
|
if top_cell.name != expected_name:
|
|
raise PrecheckFailure(
|
|
f"Top macro name mismatch: expected {expected_name}, got {top_cell.name}"
|
|
)
|
|
|
|
logging.info("Running forbidden layer check...")
|
|
for layer in forbidden_layers[tech]:
|
|
layer_info = layers[layer]
|
|
logging.info(f"* Checking {layer_info.name}")
|
|
layer_index = layout.find_layer(layer_info.layer, layer_info.data_type)
|
|
if layer_index is not None:
|
|
raise PrecheckFailure(f"Forbidden layer {layer} found in {gds}")
|
|
|
|
logging.info("Running prBoundary check...")
|
|
layer_name = boundary_layer[tech]
|
|
layer_info = layers[layer_name]
|
|
layer_index = layout.find_layer(layer_info.layer, layer_info.data_type)
|
|
if layer_index is None:
|
|
calma_index = f"{layer_info.layer}/{layer_info.data_type}"
|
|
raise PrecheckFailure(f"{layer_name} ({calma_index}) layer not found in {gds}")
|
|
|
|
|
|
def boundary_check(gds: str, tech: str):
|
|
"""Ensure that there are no shapes outside the project area."""
|
|
lib = gdstk.read_gds(gds)
|
|
tops = lib.top_level()
|
|
if len(tops) != 1:
|
|
raise PrecheckFailure("GDS top level not unique")
|
|
top = tops[0]
|
|
boundary = top.copy("test_boundary")
|
|
|
|
layers = load_layers(tech)
|
|
layer_name = boundary_layer[tech]
|
|
layer_info = layers[layer_name]
|
|
boundary.filter([(layer_info.layer, layer_info.data_type)], False)
|
|
if top.bounding_box() != boundary.bounding_box():
|
|
raise PrecheckFailure("Shapes outside project area")
|
|
|
|
|
|
def power_pin_check(verilog: str, lef: str, uses_vapwr: bool):
|
|
"""Ensure that VPWR / VGND are present and have USE definitions,
|
|
and that VAPWR is present if and only if 'uses_vapwr' is set."""
|
|
verilog_s = open(verilog).read().replace("VPWR", "VDPWR")
|
|
lef_s = open(lef).read().replace("VPWR", "VDPWR")
|
|
|
|
# naive but good enough way to ignore comments
|
|
verilog_s = re.sub("//.*", "", verilog_s)
|
|
verilog_s = re.sub("/\\*.*?\\*/", "", verilog_s, flags=(re.DOTALL | re.MULTILINE))
|
|
|
|
# this looks for a line beginning with "PIN", captures the name of the pin and the body up until its "END"
|
|
PIN_PATTERN = re.compile(
|
|
r"^\s*PIN (VPWR|VDPWR|VAPWR|VGND)\s*([\s\S]+?(?=^\s*END \1))",
|
|
flags=re.MULTILINE,
|
|
)
|
|
|
|
for ft, s in (("Verilog", verilog_s), ("LEF", lef_s)):
|
|
for pwr, ex in (("VGND", True), ("VDPWR", True), ("VAPWR", uses_vapwr)):
|
|
if (pwr in s) and not ex:
|
|
raise PrecheckFailure(f"{ft} contains {pwr}")
|
|
if not (pwr in s) and ex:
|
|
raise PrecheckFailure(f"{ft} doesn't contain {pwr}")
|
|
|
|
for match in PIN_PATTERN.finditer(lef_s):
|
|
pin, definition = match.groups()
|
|
|
|
match pin:
|
|
case "VPWR" | "VDPWR" | "VAPWR":
|
|
if "USE POWER" not in definition:
|
|
raise PrecheckFailure(
|
|
f"{pin} does not have a corresponding 'USE POWER ;'"
|
|
)
|
|
|
|
case "VGND":
|
|
if "USE GROUND" not in definition:
|
|
raise PrecheckFailure(
|
|
f"{pin} does not have a corresponding 'USE GROUND ;'"
|
|
)
|
|
|
|
case _:
|
|
raise PrecheckFailure(f"unhandled {pin}")
|
|
|
|
|
|
def layer_check(gds: str, tech: str):
|
|
"""Check that there are no invalid layers in the GDS file."""
|
|
layer_definition = load_layers(tech, only_valid=False)
|
|
lib = gdstk.read_gds(gds)
|
|
valid_layer_list = set(
|
|
map(
|
|
lambda layer_name: (
|
|
(
|
|
layer_definition[layer_name].layer,
|
|
layer_definition[layer_name].data_type,
|
|
)
|
|
if type(layer_name) is str
|
|
else layer_name
|
|
),
|
|
valid_layers[tech],
|
|
)
|
|
)
|
|
gds_layers = lib.layers_and_datatypes().union(lib.layers_and_texttypes())
|
|
excess = gds_layers - valid_layer_list
|
|
if excess:
|
|
raise PrecheckFailure(f"Invalid layers in GDS: {excess}")
|
|
|
|
|
|
def cell_name_check(gds: str):
|
|
"""Check that there are no cell names with '#' or '/' in them."""
|
|
for cell_name in gdstk.read_rawcells(gds):
|
|
if "#" in cell_name:
|
|
raise PrecheckFailure(
|
|
f"Cell name {cell_name} contains invalid character '#'"
|
|
)
|
|
if "/" in cell_name:
|
|
raise PrecheckFailure(
|
|
f"Cell_name {cell_name} contains invalid character '/'"
|
|
)
|
|
|
|
|
|
def urpm_nwell_check(gds: str, top_module: str):
|
|
"""Run a DRC check for urpm to nwell spacing."""
|
|
extra_vars = {"thr": os.cpu_count(), "top_cell": top_module}
|
|
klayout_drc(
|
|
gds=gds, check="nwell_urpm", script="nwell_urpm.drc", extra_vars=extra_vars
|
|
)
|
|
|
|
|
|
def analog_pin_check(
|
|
gds: str,
|
|
tech: str,
|
|
is_analog: bool,
|
|
uses_vapwr: bool,
|
|
analog_pins: int,
|
|
pinout: dict,
|
|
):
|
|
"""Check that every analog pin connects to a piece of metal
|
|
if and only if the pin is used according to info.yaml."""
|
|
if is_analog:
|
|
lib = gdstk.read_gds(gds)
|
|
top = lib.top_level()[0]
|
|
filtered = {}
|
|
|
|
for pin, (rect, pin_layer, via_layers) in enumerate(
|
|
analog_pin_rects(tech, uses_vapwr)
|
|
):
|
|
for layer in [pin_layer] + via_layers:
|
|
if layer not in filtered:
|
|
i = len(filtered)
|
|
lf = top.copy(f"test_lf_{i}")
|
|
lf.flatten()
|
|
lf.filter([layer], False)
|
|
filtered[layer] = lf
|
|
|
|
pin_rect = gdstk.rectangle(*rect)
|
|
pin_ring = gdstk.boolean(
|
|
gdstk.offset(pin_rect, 0.5), gdstk.offset(pin_rect, 0.1), "not"
|
|
)
|
|
|
|
pin_layer_polygons = filtered[pin_layer].polygons
|
|
connected = bool(gdstk.boolean(pin_layer_polygons, pin_ring, "and"))
|
|
for via_layer in via_layers:
|
|
via_layer_polygons = filtered[via_layer].polygons
|
|
connected = connected or bool(
|
|
gdstk.boolean(via_layer_polygons, pin_rect, "and")
|
|
)
|
|
|
|
expected_pc = pin < analog_pins
|
|
expected_pd = bool(pinout.get(f"ua[{pin}]", ""))
|
|
|
|
if connected and not expected_pc:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is connected to some metal but `analog_pins` is set to {analog_pins} in `info.yaml`. Either increase `analog_pins` to at least {pin+1}, or remove any metal or via adjacent to `ua[{pin}]`."
|
|
)
|
|
elif connected and not expected_pd:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is connected to some metal but the description of `ua[{pin}]` in the pinout section of `info.yaml` is empty. Either add a description or remove any metal or via adjacent to `ua[{pin}]`."
|
|
)
|
|
elif not connected and expected_pc:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is not connected to any adjacent metal but `analog_pins` is set to {analog_pins} in `info.yaml`. Either wire up `ua[{pin}]` to your design or decrease `analog_pins` to {pin}."
|
|
)
|
|
elif not connected and expected_pd:
|
|
raise PrecheckFailure(
|
|
f"Analog pin `ua[{pin}]` is not connected to any adjacent metal but the description of `ua[{pin}]` in the pinout section of `info.yaml` is non-empty. Either wire up `ua[{pin}]` to your design or remove the description for the disconnected pin."
|
|
)
|
|
|
|
|
|
def verilog_syntax_check(verilog: str):
|
|
"""Load the Verilog file into Yosys to verify it can be read successfully."""
|
|
|
|
logging.info(f"Running Verilog syntax check on {verilog}")
|
|
verilog_dir = os.path.dirname(verilog)
|
|
yowasp_env = os.environ.copy()
|
|
yowasp_env["YOWASP_MOUNT"] = f"{verilog_dir}={verilog_dir}"
|
|
|
|
yosys = subprocess.run(
|
|
[
|
|
"yowasp-yosys",
|
|
"-p",
|
|
f'read_verilog -sv "{verilog}"',
|
|
],
|
|
env=yowasp_env,
|
|
)
|
|
|
|
if yosys.returncode != 0:
|
|
raise PrecheckFailure("Verilog syntax check failed")
|
|
|
|
|
|
def main():
|
|
default_tech = PDK_NAME
|
|
if default_tech not in tech_names:
|
|
default_tech = tech_names[0]
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--gds", required=True)
|
|
parser.add_argument(
|
|
"--tech", required=False, default=default_tech, choices=tech_names
|
|
)
|
|
args = parser.parse_args()
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
logging.info(f"PDK_ROOT: {PDK_ROOT}")
|
|
logging.info(f"Tech: {args.tech}")
|
|
|
|
if args.gds.endswith(".gds"):
|
|
gds_stem = args.gds.removesuffix(".gds")
|
|
gds_temp = None
|
|
gds_file = args.gds
|
|
elif args.gds.endswith(".oas"):
|
|
gds_stem = args.gds.removesuffix(".oas")
|
|
gds_temp = tempfile.NamedTemporaryFile(suffix=".gds", delete=False)
|
|
gds_file = gds_temp.name
|
|
logging.info(f"Converting {args.gds} to {gds_file}")
|
|
layout = pya.Layout()
|
|
layout.read(args.gds)
|
|
layout.write(gds_file)
|
|
else:
|
|
raise PrecheckFailure("Layout file extension is neither .gds nor .oas")
|
|
|
|
tech = args.tech
|
|
if tech not in tech_names:
|
|
raise PrecheckFailure(f"Invalid tech: {tech}")
|
|
|
|
yaml_dir = os.path.dirname(args.gds)
|
|
while not os.path.exists(f"{yaml_dir}/info.yaml"):
|
|
yaml_dir = os.path.dirname(yaml_dir)
|
|
if yaml_dir in ("/", ""):
|
|
raise PrecheckFailure("info.yaml not found")
|
|
yaml_file = f"{yaml_dir}/info.yaml"
|
|
yaml_data = yaml.safe_load(open(yaml_file))
|
|
|
|
wokwi_id = yaml_data["project"].get("wokwi_id", 0)
|
|
top_module = yaml_data["project"].get("top_module", f"tt_um_wokwi_{wokwi_id}")
|
|
assert top_module == os.path.basename(gds_stem)
|
|
|
|
project_cfg = yaml_data.get("project", {})
|
|
tiles = project_cfg.get("tiles", "1x1")
|
|
analog_pins = project_cfg.get("analog_pins", 0)
|
|
is_analog = analog_pins > 0
|
|
# "uses_3v3" is the legacy name for "uses_vapwr"; still accepted for compat.
|
|
uses_vapwr = bool(project_cfg.get("uses_vapwr", project_cfg.get("uses_3v3", False)))
|
|
pinout = yaml_data.get("pinout", {})
|
|
if uses_vapwr and not is_analog:
|
|
raise PrecheckFailure("Projects with VAPWR power need at least one analog pin")
|
|
def_root = f"../tech/{tech}/def"
|
|
if is_analog:
|
|
analog_def = f"{def_root}/analog/tt_analog_{tiles}"
|
|
if uses_vapwr:
|
|
# gf180's second supply rail is "pgvaa" (its core is already 3v3, so
|
|
# "_3v3" is a misnomer); fall back to "_3v3" for techs that use it.
|
|
template_def = f"{analog_def}_pgvaa.def"
|
|
if not os.path.exists(template_def):
|
|
template_def = f"{analog_def}_3v3.def"
|
|
else:
|
|
template_def = f"{analog_def}.def"
|
|
elif tech == "ihp-sg13g2" or tech == "gf180mcuD":
|
|
template_def = f"{def_root}/tt_block_{tiles}_pgvdd.def"
|
|
else:
|
|
template_def = f"{def_root}/tt_block_{tiles}_pg.def"
|
|
logging.info(f"using def template {template_def}")
|
|
|
|
gds_dir = os.path.dirname(gds_stem)
|
|
lef_file = gds_stem + ".lef"
|
|
lef_file_alt = os.path.join(
|
|
gds_dir, "..", "lef", os.path.basename(gds_stem) + ".lef"
|
|
)
|
|
if not os.path.exists(lef_file) and os.path.exists(lef_file_alt):
|
|
lef_file = lef_file_alt
|
|
verilog_file = gds_stem + ".v"
|
|
|
|
checks = [
|
|
{
|
|
"name": "Magic DRC",
|
|
"check": lambda: magic_drc(gds_file, top_module),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "KLayout FEOL",
|
|
"check": lambda: klayout_drc(gds_file, "feol"),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "KLayout BEOL",
|
|
"check": lambda: klayout_drc(gds_file, "beol"),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "KLayout offgrid",
|
|
"check": lambda: klayout_drc(gds_file, "offgrid"),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "KLayout pin label overlapping drawing",
|
|
"check": lambda: klayout_drc(
|
|
gds_file,
|
|
"pin_label_purposes_overlapping_drawing",
|
|
"pin_label_purposes_overlapping_drawing.rb.drc",
|
|
),
|
|
},
|
|
{
|
|
"name": "KLayout SG13G2 DRC",
|
|
"check": lambda: klayout_sg13g2(gds_file),
|
|
"techs": ["ihp-sg13g2"],
|
|
},
|
|
{"name": "KLayout zero area", "check": lambda: klayout_zero_area(gds_file)},
|
|
{
|
|
"name": "KLayout Checks",
|
|
"check": lambda: klayout_checks(gds_file, top_module, tech),
|
|
},
|
|
{
|
|
"name": "Pin check",
|
|
"check": lambda: pin_check(
|
|
gds_file, lef_file, template_def, top_module, uses_vapwr, tech
|
|
),
|
|
},
|
|
{"name": "Boundary check", "check": lambda: boundary_check(gds_file, tech)},
|
|
{
|
|
"name": "Power pin check",
|
|
"check": lambda: power_pin_check(verilog_file, lef_file, uses_vapwr),
|
|
"techs": ["sky130A", "gf180mcuD"],
|
|
},
|
|
{"name": "Layer check", "check": lambda: layer_check(gds_file, tech)},
|
|
{"name": "Cell name check", "check": lambda: cell_name_check(gds_file)},
|
|
{
|
|
"name": "urpm/nwell check",
|
|
"check": lambda: urpm_nwell_check(gds_file, top_module),
|
|
"techs": ["sky130A"],
|
|
},
|
|
{
|
|
"name": "Antenna check",
|
|
"check": lambda: klayout_gf180mcuD_antenna(gds_file),
|
|
"techs": ["gf180mcuD"],
|
|
},
|
|
{
|
|
"name": "Analog pin check",
|
|
"check": lambda: analog_pin_check(
|
|
gds_file, tech, is_analog, uses_vapwr, analog_pins, pinout
|
|
),
|
|
"techs": ["sky130A", "ihp-sg13g2", "gf180mcuD"],
|
|
},
|
|
{
|
|
"name": "Verilog syntax check",
|
|
"check": lambda: verilog_syntax_check(verilog_file),
|
|
},
|
|
]
|
|
|
|
testsuite = ET.Element("testsuite", name="Tiny Tapeout Prechecks")
|
|
error_count = 0
|
|
markdown_table = "# Tiny Tapeout Precheck Results\n\n"
|
|
markdown_table += "| Check | Result |\n|-----------|--------|\n"
|
|
for check in checks:
|
|
name = check["name"]
|
|
if "techs" in check and tech not in check["techs"]:
|
|
continue
|
|
start_time = time.time()
|
|
test_case = ET.SubElement(testsuite, "testcase", name=name)
|
|
try:
|
|
check["check"]()
|
|
elapsed_time = time.time() - start_time
|
|
markdown_table += f"| {name} | ✅ |\n"
|
|
test_case.set("time", str(round(elapsed_time, 2)))
|
|
except Exception as e:
|
|
error_count += 1
|
|
elapsed_time = time.time() - start_time
|
|
markdown_table += f"| {name} | ❌ Fail: {str(e)} |\n"
|
|
test_case.set("time", str(round(elapsed_time, 2)))
|
|
error = ET.SubElement(test_case, "error", message=str(e))
|
|
error.text = traceback.format_exc()
|
|
markdown_table += "\n"
|
|
markdown_table += "In case of failure, please reach out on [discord](https://tinytapeout.com/discord) for assistance."
|
|
|
|
testsuites = ET.Element("testsuites")
|
|
testsuites.append(testsuite)
|
|
xunit_report = ET.ElementTree(testsuites)
|
|
ET.indent(xunit_report, space=" ", level=0)
|
|
xunit_report.write(f"{REPORTS_PATH}/results.xml", encoding="unicode")
|
|
|
|
with open(f"{REPORTS_PATH}/results.md", "w") as f:
|
|
f.write(markdown_table)
|
|
|
|
if gds_temp is not None:
|
|
gds_temp.close()
|
|
os.unlink(gds_temp.name)
|
|
|
|
if error_count > 0:
|
|
logging.error(f"Precheck failed for {args.gds}! 😭")
|
|
logging.error(f"See {REPORTS_PATH} for more details")
|
|
logging.error(f"Markdown report:\n{markdown_table}")
|
|
exit(1)
|
|
else:
|
|
logging.info(f"Precheck passed for {args.gds}! 🎉")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|