| #!/usr/bin/env python3 |
| # Licensed to the Apache Software Foundation (ASF) under one |
| # or more contributor license agreements. See the NOTICE file |
| # distributed with this work for additional information |
| # regarding copyright ownership. The ASF licenses this file |
| # to you under the Apache License, Version 2.0 (the |
| # "License"); you may not use this file except in compliance |
| # with the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, |
| # software distributed under the License is distributed on an |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| # KIND, either express or implied. See the License for the |
| # specific language governing permissions and limitations |
| # under the License. |
| |
| import argparse |
| import json |
| import os |
| import tomllib |
| from collections import defaultdict, deque |
| from dataclasses import dataclass |
| from pathlib import Path |
| |
| |
| SCRIPT_PATH = Path(__file__).resolve() |
| PROJECT_DIR = SCRIPT_PATH.parents[3] |
| PUBLISH_GLOBS = ( |
| "core/Cargo.toml", |
| "core/core/Cargo.toml", |
| "core/layers/*/Cargo.toml", |
| "core/services/*/Cargo.toml", |
| "integrations/*/Cargo.toml", |
| ) |
| |
| |
| @dataclass(frozen=True) |
| class Package: |
| manifest_path: Path |
| manifest_dir: Path |
| path: str |
| |
| |
| def discover_publishable_packages(project_dir: Path) -> dict[Path, Package]: |
| packages: dict[Path, Package] = {} |
| |
| for pattern in PUBLISH_GLOBS: |
| for manifest_path in sorted(project_dir.glob(pattern)): |
| with manifest_path.open("rb") as fp: |
| manifest = tomllib.load(fp) |
| |
| package = manifest.get("package") |
| if not package or package.get("publish") is False: |
| continue |
| |
| manifest_dir = manifest_path.parent.resolve() |
| packages[manifest_dir] = Package( |
| manifest_path=manifest_path.resolve(), |
| manifest_dir=manifest_dir, |
| path=manifest_dir.relative_to(project_dir).as_posix(), |
| ) |
| |
| return packages |
| |
| |
| def iter_local_dependencies(manifest: dict, manifest_dir: Path) -> list[Path]: |
| dependencies: list[Path] = [] |
| |
| def visit_table(table: dict | None) -> None: |
| if not isinstance(table, dict): |
| return |
| |
| for dependency in table.values(): |
| if not isinstance(dependency, dict): |
| continue |
| path = dependency.get("path") |
| if not isinstance(path, str): |
| continue |
| dependencies.append((manifest_dir / path).resolve()) |
| |
| for name in ("dependencies", "build-dependencies"): |
| visit_table(manifest.get(name)) |
| |
| for target in manifest.get("target", {}).values(): |
| if not isinstance(target, dict): |
| continue |
| for name in ("dependencies", "build-dependencies"): |
| visit_table(target.get(name)) |
| |
| return dependencies |
| |
| |
| def plan(project_dir: Path = PROJECT_DIR) -> list[str]: |
| project_dir = project_dir.resolve() |
| packages = discover_publishable_packages(project_dir) |
| |
| graph: dict[Path, set[Path]] = defaultdict(set) |
| indegree = {manifest_dir: 0 for manifest_dir in packages} |
| |
| for manifest_dir, package in packages.items(): |
| with package.manifest_path.open("rb") as fp: |
| manifest = tomllib.load(fp) |
| |
| for dependency_dir in iter_local_dependencies(manifest, manifest_dir): |
| if dependency_dir not in packages: |
| continue |
| if manifest_dir in graph[dependency_dir]: |
| continue |
| |
| graph[dependency_dir].add(manifest_dir) |
| indegree[manifest_dir] += 1 |
| |
| queue = deque( |
| sorted( |
| (manifest_dir for manifest_dir, degree in indegree.items() if degree == 0), |
| key=lambda manifest_dir: packages[manifest_dir].path, |
| ) |
| ) |
| |
| ordered: list[str] = [] |
| while queue: |
| manifest_dir = queue.popleft() |
| ordered.append(packages[manifest_dir].path) |
| |
| for dependent in sorted( |
| graph[manifest_dir], key=lambda dependent: packages[dependent].path |
| ): |
| indegree[dependent] -= 1 |
| if indegree[dependent] == 0: |
| queue.append(dependent) |
| |
| if len(ordered) != len(packages): |
| raise RuntimeError("failed to resolve publish order for Rust crates") |
| |
| return ordered |
| |
| |
| def write_github_output(packages: list[str]) -> None: |
| github_output = os.environ.get("GITHUB_OUTPUT") |
| if not github_output: |
| raise RuntimeError("GITHUB_OUTPUT is not set") |
| |
| with Path(github_output).open("a", encoding="utf-8") as fp: |
| fp.write(f"packages={json.dumps(packages)}\n") |
| |
| |
| def main() -> int: |
| parser = argparse.ArgumentParser( |
| description="Plan the publish order for Rust crates released from this repository." |
| ) |
| parser.add_argument( |
| "--project-dir", |
| type=Path, |
| default=PROJECT_DIR, |
| help="Path to the repository root.", |
| ) |
| parser.add_argument( |
| "--github-output", |
| action="store_true", |
| help="Write the planned package list to GITHUB_OUTPUT as `packages=<json>`.", |
| ) |
| args = parser.parse_args() |
| |
| packages = plan(args.project_dir) |
| print(json.dumps(packages)) |
| |
| if args.github_output: |
| write_github_output(packages) |
| |
| return 0 |
| |
| |
| if __name__ == "__main__": |
| raise SystemExit(main()) |