diff --git a/.github/workflows/api-drift.yml b/.github/workflows/api-drift.yml new file mode 100644 index 0000000..1c5df54 --- /dev/null +++ b/.github/workflows/api-drift.yml @@ -0,0 +1,83 @@ +name: API drift detection + +on: + schedule: + - cron: '0 8 * * 1' # Every Monday at 8:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + check-api: + runs-on: ubuntu-latest + steps: + - name: Checkout wiki + uses: actions/checkout@v4 + + - name: Checkout micropython-steami-lib + uses: actions/checkout@v4 + with: + repository: steamicc/micropython-steami-lib + path: micropython-steami-lib + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check for API changes + id: drift + run: | + set +e + python scripts/check-api-drift.py micropython-steami-lib/lib scripts/api-snapshot.json > /tmp/api-drift-report.md + EXIT_CODE=$? + set -e + + echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" + + if [ "$EXIT_CODE" -eq 0 ]; then + echo "changes=false" >> "$GITHUB_OUTPUT" + elif [ "$EXIT_CODE" -eq 1 ]; then + echo "changes=true" >> "$GITHUB_OUTPUT" + else + echo "API drift check failed with exit code $EXIT_CODE" >&2 + exit "$EXIT_CODE" + fi + + - name: Create issue if changes detected + if: steps.drift.outputs.changes == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('/tmp/api-drift-report.md', 'utf8'); + + // Check if an open issue already exists + const existing = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'api-drift', + per_page: 1, + }); + + if (existing.data.length > 0) { + // Update existing issue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.data[0].number, + body: `Mise à jour ${new Date().toISOString().split('T')[0]} :\n\n${report}`, + }); + } else { + // Create new issue + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: 'docs: Changements API détectés dans micropython-steami-lib', + body: `${report}\n\n---\n*Détecté automatiquement par le workflow api-drift.*`, + labels: ['api-drift'], + }); + } diff --git a/.gitignore b/.gitignore index 32c1145..3f9ac85 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ build/ .docusaurus/ .external/ +__pycache__/ +*.pyc diff --git a/scripts/api-snapshot.json b/scripts/api-snapshot.json new file mode 100644 index 0000000..bf0bf40 --- /dev/null +++ b/scripts/api-snapshot.json @@ -0,0 +1,464 @@ +{ + "apds9960": { + "APDS9960": { + "__init__": ["i2c", "address", "valid_id"], + "ambient_light": [], + "blue_light": [], + "clear_ambient_light_int": [], + "clear_proximity_int": [], + "data_ready": [], + "decode_gesture": [], + "device_id": [], + "disable_gesture_sensor": [], + "disable_light_sensor": [], + "disable_proximity_sensor": [], + "enable_gesture_sensor": ["interrupts"], + "enable_light_sensor": ["interrupts"], + "enable_proximity_sensor": ["interrupts"], + "gesture": [], + "get_ambient_light_gain": [], + "get_ambient_light_int_enable": [], + "get_gesture_enter_thresh": [], + "get_gesture_exit_thresh": [], + "get_gesture_gain": [], + "get_gesture_int_enable": [], + "get_gesture_led_drive": [], + "get_gesture_mode": [], + "get_gesture_wait_time": [], + "get_led_boost": [], + "get_led_drive": [], + "get_light_int_high_threshold": [], + "get_light_int_low_threshold": [], + "get_mode": [], + "get_prox_gain_comp_enable": [], + "get_prox_int_high_thresh": [], + "get_prox_int_low_thresh": [], + "get_prox_photo_mask": [], + "get_proximity_gain": [], + "get_proximity_int_enable": [], + "get_proximity_int_high_threshold": [], + "get_proximity_int_low_threshold": [], + "green_light": [], + "is_gesture_available": [], + "light_ready": [], + "power_off": [], + "power_on": [], + "process_gesture_data": [], + "proximity": [], + "proximity_ready": [], + "red_light": [], + "reset_gesture_parameters": [], + "set_ambient_light_gain": ["drive"], + "set_ambient_light_int_enable": ["enable"], + "set_gesture_enter_thresh": ["threshold"], + "set_gesture_exit_thresh": ["threshold"], + "set_gesture_gain": ["gain"], + "set_gesture_int_enable": ["enable"], + "set_gesture_led_drive": ["drive"], + "set_gesture_mode": ["enable"], + "set_gesture_wait_time": ["time"], + "set_led_boost": ["boost"], + "set_led_drive": ["drive"], + "set_light_int_high_threshold": ["threshold"], + "set_light_int_low_threshold": ["threshold"], + "set_mode": ["mode", "enable"], + "set_prox_gain_comp_enable": ["enable"], + "set_prox_int_high_thresh": ["threshold"], + "set_prox_int_low_thresh": ["threshold"], + "set_prox_photo_mask": ["mask"], + "set_proximity_gain": ["gain"], + "set_proximity_int_enable": ["enable"], + "set_proximity_int_high_threshold": ["threshold"], + "set_proximity_int_low_threshold": ["threshold"] + }, + "APDS9960InvalidDevId": { + "__init__": ["id", "valid_ids"] + }, + "APDS9960InvalidMode": { + "__init__": ["mode"] + }, + "GestureData": { + "__init__": [] + } + }, + "bq27441": { + "BQ27441": { + "__init__": ["i2c", "capacity_mAh", "address", "gpout_pin"], + "block_data_checksum": [], + "block_data_class": ["_id"], + "block_data_control": [], + "block_data_offset": ["offset"], + "capacity": ["capacity_measure_type"], + "capacity_full": [], + "capacity_remaining": [], + "compute_block_checksum": [], + "configure_gpout_input": [], + "configure_gpout_output": [], + "current": ["current_measure_type"], + "current_average": [], + "device_id": [], + "disable_shutdown_mode": [], + "enable_shutdown_mode": [], + "enter_config": ["user_control"], + "enter_shutdown_mode": [], + "execute_control_word": ["function"], + "exit_config": ["resim"], + "flags": [], + "get_time_ms": [], + "gpout_function": [], + "gpout_polarity": [], + "is_valid_device": [], + "op_config": [], + "power": [], + "power_off": [], + "power_on": [], + "pulse_gpout": [], + "read_block_data": ["offset"], + "read_control_word": ["function"], + "read_extended_data": ["class_id", "offset"], + "read_word": ["sub_address", "format"], + "reset": [], + "seal": [], + "sealed": [], + "set_capacity": ["capacity"], + "set_gpout_function": ["gpout_function"], + "set_gpout_polarity": ["active_high"], + "set_soc1_thresholds": ["set_soc", "clear_soc"], + "set_socf_thresholds": ["set_socf", "clear_socf"], + "set_soci_delta": ["delta"], + "soc": ["soc_measure_type"], + "soc1_clear_threshold": [], + "soc1_set_threshold": [], + "soc_flag": [], + "socf_clear_threshold": [], + "socf_flag": [], + "socf_set_threshold": [], + "soci_delta": [], + "soft_reset": [], + "soh": ["soh_measure_type"], + "state_of_charge": [], + "state_of_health": [], + "temperature": ["temp_measure_type"], + "unseal": [], + "voltage_mv": [], + "write_block_checksum": ["csum"], + "write_block_data": ["offset", "data"], + "write_extended_data": ["class_id", "offset", "data", "length"], + "write_op_config": ["value"] + }, + "CapacityMeasureType": { + "__init__": ["value"] + }, + "CurrentMeasureType": { + "__init__": ["value"] + }, + "GpoutFunctionType": { + "__init__": ["value"] + }, + "SocMeasureType": { + "__init__": ["value"] + }, + "SohMeasureType": { + "__init__": ["value"] + }, + "TempMeasureType": { + "__init__": ["value"] + } + }, + "daplink_flash": { + "DaplinkFlash": { + "__init__": ["i2c", "address"], + "busy": [], + "clear_config": [], + "clear_flash": [], + "device_id": [], + "get_filename": [], + "read": ["length"], + "read_config": [], + "read_sector": ["sector"], + "set_filename": ["name", "ext"], + "write": ["data"], + "write_config": ["data", "offset"], + "write_line": ["text"] + } + }, + "hts221": { + "HTS221": { + "__init__": ["i2c", "address"], + "calibrate_temperature": ["ref_low", "measured_low", "ref_high", "measured_high"], + "data_ready": [], + "device_id": [], + "get_av": [], + "get_odr": [], + "humidity": [], + "humidity_ready": [], + "power_off": [], + "power_on": [], + "read": [], + "read_one_shot": [], + "reboot": [], + "set_av": ["av"], + "set_continuous": ["odr"], + "set_odr": ["odr"], + "set_temp_offset": ["offset_c"], + "temperature": [], + "temperature_ready": [], + "trigger_one_shot": [] + } + }, + "ism330dl": { + "ISM330DL": { + "__init__": ["i2c", "address"], + "accel_ready": [], + "acceleration_g": [], + "acceleration_ms2": [], + "acceleration_raw": [], + "calibrate_temperature": ["ref_low", "measured_low", "ref_high", "measured_high"], + "check_device": [], + "configure_accel": ["odr", "scale"], + "configure_gyro": ["odr", "scale"], + "data_ready": [], + "device_id": [], + "gyro_ready": [], + "gyroscope_dps": [], + "gyroscope_rads": [], + "gyroscope_raw": [], + "motion": [], + "orientation": [], + "power_off": [], + "power_on": [], + "set_temp_offset": ["offset_c"], + "soft_reset": [], + "temperature": [], + "temperature_raw": [], + "temperature_ready": [] + } + }, + "lis2mdl": { + "LIS2MDL": { + "__init__": ["i2c", "address", "odr_hz", "temp_comp", "low_power", "drdy_enable"], + "calibrate_apply": ["x", "y", "z"], + "calibrate_minmax_2d": ["samples", "delay_ms"], + "calibrate_minmax_3d": ["samples", "delay_ms"], + "calibrate_quality": ["samples_check", "delay_ms"], + "calibrate_reset": [], + "calibrate_step": [], + "calibrate_temperature": ["ref_low", "measured_low", "ref_high", "measured_high"], + "calibrated_field": [], + "data_ready": [], + "device_id": [], + "direction_label": ["angle"], + "get_mode": [], + "heading_flat_only": [], + "heading_from_vectors": ["x", "y", "z", "calibrated"], + "heading_with_tilt_compensation": ["read_accel"], + "is_idle": [], + "magnetic_field": [], + "magnetic_field_raw": [], + "magnetic_field_ut": [], + "magnitude_ut": [], + "power_off": [], + "power_on": ["mode"], + "read_all": [], + "read_calibration": [], + "read_hw_offsets": [], + "read_int_source": [], + "read_one_shot": [], + "read_registers": ["start_addr", "length"], + "read_temperature_raw": [], + "reboot": ["wait_ms"], + "set_bdu": ["enable"], + "set_calibrate_step": ["xoff", "yoff", "zoff", "xscale", "yscale", "zscale"], + "set_continuous": ["hz"], + "set_declination": ["deg"], + "set_endianness": ["big_endian"], + "set_heading_filter": ["alpha"], + "set_heading_offset": ["deg"], + "set_hw_offsets": ["x", "y", "z"], + "set_low_pass": ["enabled"], + "set_low_power": ["enabled"], + "set_mode": ["mode"], + "set_odr": ["hz"], + "set_offset_cancellation": ["enabled", "oneshot"], + "set_temp_offset": ["offset_c"], + "soft_reset": ["wait_ms"], + "temperature": [], + "trigger_one_shot": [], + "use_spi_4wire": ["enable"] + } + }, + "mcp23009e": { + "MCP23009ActiveLowPin": { + "__init__": ["mcp", "pin_number", "mode", "pull", "value"], + "init": ["mode", "pull", "value"], + "irq": ["handler", "trigger", "hard"], + "mode": ["mode"], + "off": [], + "on": [], + "pin_number": [], + "pull": ["pull"], + "toggle": [], + "value": ["x"] + }, + "MCP23009E": { + "__init__": ["i2c", "address", "reset_pin", "interrupt_pin"], + "disable_interrupt": ["gpx"], + "get_defval": [], + "get_gpinten": [], + "get_gpio": [], + "get_gppu": [], + "get_intcap": [], + "get_intcon": [], + "get_intf": [], + "get_iocon": [], + "get_iodir": [], + "get_ipol": [], + "get_level": ["gpx"], + "get_olat": [], + "interrupt_event": [], + "interrupt_on_change": ["gpx", "callback"], + "interrupt_on_falling": ["gpx", "callback"], + "interrupt_on_raising": ["gpx", "callback"], + "power_off": [], + "power_on": [], + "reset": [], + "set_defval": ["value"], + "set_gpinten": ["value"], + "set_gpio": ["value"], + "set_gppu": ["value"], + "set_intcon": ["value"], + "set_iocon": ["config"], + "set_iodir": ["value"], + "set_ipol": ["value"], + "set_level": ["gpx", "level"], + "set_olat": ["value"], + "setup": ["gpx", "direction", "pullup", "polarity"] + }, + "MCP23009Pin": { + "__init__": ["mcp", "pin_number", "mode", "pull", "value"], + "init": ["mode", "pull", "value"], + "irq": ["handler", "trigger", "hard"], + "mode": ["mode"], + "off": [], + "on": [], + "pull": ["pull"], + "toggle": [], + "value": ["x"] + } + }, + "ssd1327": { + "SSD1327": { + "__init__": ["width", "height"], + "contrast": ["contrast"], + "fill": ["col"], + "init_display": [], + "invert": ["invert"], + "line": ["x1", "y1", "x2", "y2", "col"], + "lookup": ["data"], + "pixel": ["x", "y", "col"], + "power_off": [], + "power_on": [], + "rotate": ["rotate"], + "scroll": ["dx", "dy"], + "show": [], + "text": ["string", "x", "y", "col"], + "write_cmd": [], + "write_data": [] + }, + "SSD1327_I2C": { + "__init__": ["width", "height", "i2c", "address"], + "write_cmd": ["cmd"], + "write_data": ["data_buf"] + }, + "SSD1327_SPI": { + "__init__": ["width", "height", "spi", "dc", "res", "cs"], + "reset": [], + "write_cmd": ["cmd"], + "write_data": ["buf"] + }, + "WS_OLED_128X128_I2C": { + "__init__": ["i2c", "address"] + }, + "WS_OLED_128X128_SPI": { + "__init__": ["spi", "dc", "res", "cs"] + } + }, + "steami_config": { + "SteamiConfig": { + "__init__": ["flash"], + "apply_temperature_calibration": ["sensor_instance"], + "board_name": ["value"], + "board_revision": ["value"], + "get_temperature_calibration": ["sensor"], + "load": [], + "save": [], + "set_temperature_calibration": ["sensor", "gain", "offset"] + } + }, + "vl53l1x": { + "VL53L1X": { + "__init__": ["i2c", "address"], + "data_ready": [], + "device_id": [], + "distance_mm": [], + "power_off": [], + "power_on": [], + "read": [], + "reset": [], + "start_ranging": [], + "stop_ranging": [] + } + }, + "wsen-hids": { + "WSEN_HIDS": { + "__init__": ["i2c", "address", "check_device", "enable_bdu", "avg_t", "avg_h"], + "calibrate_temperature": ["ref_low", "measured_low", "ref_high", "measured_high"], + "check_device": [], + "data_ready": [], + "device_id": [], + "enable_bdu": ["enabled"], + "enable_heater": ["enabled"], + "humidity": [], + "humidity_ready": [], + "power_off": [], + "power_on": [], + "read": [], + "read_one_shot": ["timeout_ms"], + "reboot": [], + "set_average": ["avg_t", "avg_h"], + "set_continuous": ["odr"], + "set_one_shot_mode": [], + "set_temp_offset": ["offset_c"], + "temperature": [], + "temperature_ready": [], + "trigger_one_shot": [] + } + }, + "wsen-pads": { + "WSEN_PADS": { + "__init__": ["i2c", "address"], + "calibrate_temperature": ["ref_low", "measured_low", "ref_high", "measured_high"], + "data_ready": [], + "device_id": [], + "disable_low_pass": [], + "enable_low_pass": ["strong"], + "power_off": [], + "power_on": ["odr"], + "pressure_hpa": [], + "pressure_kpa": [], + "pressure_pa": [], + "pressure_raw": [], + "pressure_ready": [], + "read": [], + "read_one_shot": ["low_noise"], + "reboot": [], + "set_continuous": ["odr", "low_noise", "low_pass", "low_pass_strong"], + "set_temp_offset": ["offset_c"], + "soft_reset": [], + "temperature": [], + "temperature_raw": [], + "temperature_ready": [], + "trigger_one_shot": ["low_noise"] + } + } +} diff --git a/scripts/check-api-drift.py b/scripts/check-api-drift.py new file mode 100644 index 0000000..eebd70a --- /dev/null +++ b/scripts/check-api-drift.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Compare current micropython-steami-lib API against a stored snapshot. + +Detects added, removed, and modified methods in each driver. + +Usage: + python scripts/check-api-drift.py + +Exit codes: + 0 — no changes detected + 1 — changes detected (prints a Markdown report to stdout) + 2 — error +""" + +import importlib.util +import json +import sys +from pathlib import Path + + +def load_extract_module(): + """Dynamically load extract-api.py (name contains a hyphen).""" + script_dir = Path(__file__).parent + extract_path = script_dir / "extract-api.py" + spec = importlib.util.spec_from_file_location("extract_api", extract_path) + if spec is None or spec.loader is None: + print( + f"Error: could not create a module spec for {extract_path}", + file=sys.stderr, + ) + sys.exit(2) + try: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + except FileNotFoundError: + print( + f"Error: extract-api.py not found at {extract_path}", + file=sys.stderr, + ) + sys.exit(2) + except Exception as exc: + print( + f"Error: failed to load extract-api module from {extract_path}: {exc}", + file=sys.stderr, + ) + sys.exit(2) + return mod + + +def load_snapshot(snapshot_path): + """Load and parse the JSON snapshot file.""" + try: + content = Path(snapshot_path).read_text(encoding="utf-8") + except OSError as e: + print(f"Error reading snapshot file '{snapshot_path}': {e}", file=sys.stderr) + sys.exit(2) + + try: + return json.loads(content) + except json.JSONDecodeError as e: + print( + f"Error parsing JSON from snapshot file '{snapshot_path}': {e}", + file=sys.stderr, + ) + sys.exit(2) + + +def compare_apis(current, stored): + """Compare current and stored API snapshots, return list of changes.""" + changes = [] + all_drivers = sorted(set(list(current.keys()) + list(stored.keys()))) + + for driver in all_drivers: + cur_classes = current.get(driver, {}) + old_classes = stored.get(driver, {}) + + if driver not in stored: + changes.append(f"### Nouveau driver : `{driver}`") + for cls, methods in cur_classes.items(): + changes.append(f"- Classe `{cls}` avec {len(methods)} méthodes") + continue + + if driver not in current: + changes.append(f"### Driver supprimé : `{driver}`") + continue + + all_classes = sorted(set(list(cur_classes.keys()) + list(old_classes.keys()))) + driver_changes = [] + + for cls in all_classes: + cur_methods = cur_classes.get(cls, {}) + old_methods = old_classes.get(cls, {}) + + if cls not in old_classes: + driver_changes.append(f"- Nouvelle classe `{cls}`") + continue + if cls not in cur_classes: + driver_changes.append(f"- Classe supprimée `{cls}`") + continue + + added = set(cur_methods) - set(old_methods) + removed = set(old_methods) - set(cur_methods) + common = set(cur_methods) & set(old_methods) + + for m in sorted(removed): + driver_changes.append( + f"- `{cls}.{m}()` supprimée (**breaking change**)" + ) + for m in sorted(added): + params = ", ".join(cur_methods[m]) + driver_changes.append(f"- `{cls}.{m}({params})` ajoutée") + for m in sorted(common): + if cur_methods[m] != old_methods[m]: + old_params = ", ".join(old_methods[m]) + new_params = ", ".join(cur_methods[m]) + driver_changes.append( + f"- `{cls}.{m}()` paramètres modifiés : " + f"`({old_params})` -> `({new_params})`" + ) + + if driver_changes: + changes.append(f"### `{driver}`") + changes.extend(driver_changes) + + return changes + + +def main(): + if len(sys.argv) < 3: + print( + f"Usage: {sys.argv[0]} ", + file=sys.stderr, + ) + sys.exit(2) + + lib_dir = sys.argv[1] + snapshot_file = sys.argv[2] + + try: + extract_api = load_extract_module() + current = extract_api.scan_all_drivers(lib_dir) + stored = load_snapshot(snapshot_file) + except Exception as exc: + print(f"Error while extracting API or loading snapshot: {exc}", file=sys.stderr) + sys.exit(2) + + changes = compare_apis(current, stored) + + if not changes: + print("No API changes detected.") + sys.exit(0) + + report = "## Changements d'API détectés\n\n" + "\n".join(changes) + print(report) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/extract-api.py b/scripts/extract-api.py new file mode 100644 index 0000000..0ea6e4e --- /dev/null +++ b/scripts/extract-api.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Extract public API signatures from micropython-steami-lib drivers. + +Parses each driver's device.py (and other public modules) using the ast +module and outputs a JSON snapshot of all public classes and their public +methods with parameter lists. + +Usage: + python scripts/extract-api.py [--output ] + +Example: + python scripts/extract-api.py /path/to/micropython-steami-lib/lib + python scripts/extract-api.py /path/to/micropython-steami-lib/lib --output scripts/api-snapshot.json +""" + +import ast +import json +import sys +from pathlib import Path + + +def extract_methods(cls_node): + """Extract public method signatures from a class AST node.""" + methods = {} + for item in cls_node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + # Skip private/dunder methods except for __init__, whose signature + # is part of the public instantiation API. + if item.name.startswith("_") and item.name != "__init__": + continue + args = [] + # Positional-only arguments (Python 3.8+) + for arg in getattr(item.args, "posonlyargs", []): + if arg.arg == "self": + continue + args.append(arg.arg) + # Positional-or-keyword arguments + for arg in item.args.args: + if arg.arg == "self": + continue + args.append(arg.arg) + # *args (vararg) + if item.args.vararg is not None: + if item.args.vararg.arg != "self": + args.append("*" + item.args.vararg.arg) + # Keyword-only arguments + for arg in item.args.kwonlyargs: + if arg.arg == "self": + continue + args.append(arg.arg) + # **kwargs (kwarg) + if item.args.kwarg is not None: + if item.args.kwarg.arg != "self": + args.append("**" + item.args.kwarg.arg) + methods[item.name] = args + return methods + + +def extract_classes(source_path): + """Extract public classes and their methods from a Python file.""" + try: + source = source_path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(source_path)) + except (SyntaxError, UnicodeDecodeError, OSError) as exc: + print(f"Skipping {source_path}: {exc}", file=sys.stderr) + return {} + + classes = {} + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and not node.name.startswith("_"): + methods = extract_methods(node) + if methods: + classes[node.name] = methods + return classes + + +def scan_driver(driver_dir): + """Scan a driver directory for all public Python modules.""" + result = {} + # Look for the inner package directory (e.g., lib/ism330dl/ism330dl/) + for subdir in sorted(driver_dir.iterdir()): + if subdir.is_dir() and not subdir.name.startswith((".", "examples", "__")): + for py_file in sorted(subdir.glob("*.py")): + if py_file.name.startswith("_") or py_file.name == "const.py": + continue + classes = extract_classes(py_file) + result.update(classes) + return result + + +def scan_all_drivers(lib_dir): + """Scan all drivers in the lib directory.""" + lib_path = Path(lib_dir) + snapshot = {} + for driver_dir in sorted(lib_path.iterdir()): + if not driver_dir.is_dir() or driver_dir.name.startswith("."): + continue + classes = scan_driver(driver_dir) + if classes: + snapshot[driver_dir.name] = classes + return snapshot + + +def main(): + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} [--output ]", file=sys.stderr) + sys.exit(1) + + lib_dir = sys.argv[1] + output_file = None + if "--output" in sys.argv: + idx = sys.argv.index("--output") + if idx + 1 < len(sys.argv): + output_file = sys.argv[idx + 1] + + snapshot = scan_all_drivers(lib_dir) + result = json.dumps(snapshot, indent=2, ensure_ascii=False, sort_keys=True) + + if output_file: + Path(output_file).write_text(result + "\n", encoding="utf-8") + print(f"Snapshot written to {output_file} ({len(snapshot)} drivers)") + else: + print(result) + + +if __name__ == "__main__": + main()