From ff6735c8abad13efff56e0367f2add646532ec21 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Fri, 16 Jan 2026 20:17:32 +0100 Subject: [PATCH] ci: add device list update workflow and script --- .github/scripts/retrieve_device_classes.py | 97 +++++++++++++++++++++ .github/workflows/device-list-update.yml | 98 ++++++++++++++++++++++ .github/workflows/semantic_release.yml | 7 +- 3 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/retrieve_device_classes.py create mode 100644 .github/workflows/device-list-update.yml diff --git a/.github/scripts/retrieve_device_classes.py b/.github/scripts/retrieve_device_classes.py new file mode 100644 index 0000000..2ad4104 --- /dev/null +++ b/.github/scripts/retrieve_device_classes.py @@ -0,0 +1,97 @@ +""" +This module contains helper methods to retrieve a list of all devices in a directory. +It does so by recursively traversing the directory and checking if the file contains a +class that inherits from the Device class. +""" + +import importlib.util +import os + +from ophyd import Device + + +def get_devices(repo_name: str, ignore: str = "") -> dict: + """ + Get all devices in a directory. + + Args: + directory (str): The directory to search for devices. + ignore (str): A comma-separated list of module names to ignore. + + Returns: + list: A list of all devices in the directory. + """ + devices = {} + + anchor_module = importlib.import_module(f"{repo_name}.devices") + directory = os.path.dirname(anchor_module.__file__) + + for root, _, files in os.walk(directory): + for file in files: + if not file.endswith(".py") or file.startswith("__"): + continue + + path = os.path.join(root, file) + subs = os.path.dirname(os.path.relpath(path, directory)).split("/") + if len(subs) == 1 and not subs[0]: + module_name = file.split(".")[0] + else: + module_name = ".".join(subs + [file.split(".")[0]]) + + if module_name in ignore.split(","): + continue + module = importlib.import_module(f"{repo_name}.devices.{module_name}") + + for name in dir(module): + obj = getattr(module, name) + if not hasattr(obj, "__module__") or obj.__module__ != module.__name__: + continue + if isinstance(obj, type) and issubclass(obj, Device) and obj is not Device: + devices[obj.__name__] = obj + + return dict(sorted(devices.items(), key=lambda x: x[0].lower())) + + +def write_device_list(devices: dict, file_name: str, repo_name: str, append=False): + """ + Write the list of devices to a file. + + Args: + devices (list): A list of devices. + file_out (str): The file to write the devices to. + repo_name (str): The repository name for linking to the source code. + append (bool): Whether to append to the file or overwrite it. + """ + if not append or not os.path.exists(file_name): + with open(file_name, "w", encoding="utf-8") as output_file: + output_file.write("// This file was autogenerated. Do not edit it manually.\n") + output_file.write("## Device List\n") + + with open(file_name, "a", encoding="utf-8") as output_file: + output_file.write(f"### {repo_name} \n") + output_file.write("| Device | Documentation | Module |\n") + output_file.write("| :----- | :------------- | :------ |\n") + for dev, cls in devices.items(): + doc = cls.__doc__ + doc = "" if doc is None else doc.replace("\n", "
") + output_file.write( + f"| {dev} | {doc} | [{cls.__module__}](https://github.com/bec-project/{repo_name}/tree/main/{cls.__module__.replace('.', '/')}.py) |\n" + ) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Retrieve a list of devices in a directory.") + parser.add_argument("repo", type=str, help="The repository to link to.") + parser.add_argument("output", type=str, help="The file to write the devices to.") + parser.add_argument( + "--append", action="store_true", help="Append to the file instead of overwriting it." + ) + parser.add_argument( + "--ignore", type=str, help="Ignore the specified modules (comma-separated).", default="" + ) + + args = parser.parse_args() + devs = get_devices(args.repo, ignore=args.ignore) + write_device_list(devs, args.output, args.repo, append=args.append) diff --git a/.github/workflows/device-list-update.yml b/.github/workflows/device-list-update.yml new file mode 100644 index 0000000..8bce45e --- /dev/null +++ b/.github/workflows/device-list-update.yml @@ -0,0 +1,98 @@ +name: Update device list + +on: + push: + branches: + - main + +jobs: + device_list_update: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: device-list-update-${{ github.ref_name }} + cancel-in-progress: true + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # required for git diff / reset + ssh-key: ${{ secrets.CI_DEPLOY_SSH_KEY }} + ssh-known-hosts: ${{ secrets.CI_DEPLOY_SSH_KNOWN_HOSTS }} + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Setup | Force release branch to be at workflow sha + run: | + git reset --hard ${{ github.sha }} + + - name: Evaluate | Verify upstream has NOT changed + shell: bash + run: | + set +o pipefail + + UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)" + printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" + + set -o pipefail + + if [ -z "$UPSTREAM_BRANCH_NAME" ]; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" + exit 1 + fi + + git fetch "${UPSTREAM_BRANCH_NAME%%/*}" + + if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then + printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" + exit 1 + fi + + HEAD_SHA="$(git rev-parse HEAD)" + + if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then + printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" + printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." + exit 1 + fi + + printf '%s\n' "Verified upstream branch has not changed, continuing..." + + - name: Install Python dependencies + run: | + pip install . + + - name: Generate device list + run: | + python .github/scripts/retrieve_device_classes.py \ + "ophyd_devices" \ + "./ophyd_devices/devices/device_list.md" \ + --ignore areadetector.plugins + + - name: Commit and push if device list changed + run: | + FILE="./ophyd_devices/devices/device_list.md" + + if [ -f "$FILE" ]; then + git add "$FILE" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if ! git diff-index --quiet HEAD --; then + git commit -m "docs: Update device list" + + git push origin "${{ github.ref_name }}" + + echo "Device list updated" + else + echo "No changes detected" + fi + else + echo "Device list file not found" + fi diff --git a/.github/workflows/semantic_release.yml b/.github/workflows/semantic_release.yml index 1edc111..60f47d4 100644 --- a/.github/workflows/semantic_release.yml +++ b/.github/workflows/semantic_release.yml @@ -9,8 +9,6 @@ on: permissions: contents: read - - jobs: release: runs-on: ubuntu-latest @@ -39,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Setup | Force release branch to be at workflow sha run: | @@ -49,9 +47,6 @@ jobs: # the upstream branch while this workflow was running. This is important # because we are committing a version change (--commit). You may omit this step # if you have 'commit: false' in your configuration. - # - # You may consider moving this to a repo script and call it from this step instead - # of writing it in-line. shell: bash run: | set +o pipefail