sunbeam-charms/repository.py
Guillaume Boutry 16a65cf4e4
Refactor monorepo build-system
Introduce a per-charm configuration file `.sunbeam-build.yaml`,
containing information about needed external libraries, internal
libraries, and configuration templates.

Introduce new management script `repository.py`, responsible for
preparing, cleaning and updating libs.

Move all internal libraries back to their original charms. Each charm is
responsible of its own libraries.

Change-Id: I9edabed1c252cae60fcd945b931952aeaef12481
Signed-off-by: Guillaume Boutry <guillaume.boutry@canonical.com>
2024-07-08 21:51:09 +02:00

389 lines
12 KiB
Python
Executable File

#!/usr/bin/env python
import logging
import argparse
import dataclasses
import pathlib
import shutil
import subprocess
import yaml
ROOT_DIR = pathlib.Path(__file__).parent
EXTERNAL_LIB_DIR = ROOT_DIR / "libs" / "external" / "lib"
OPS_SUNBEAM_DIR = ROOT_DIR / "ops-sunbeam" / "ops_sunbeam"
BUILD_FILE = ".sunbeam-build.yaml"
UTILITY_FILES = [
ROOT_DIR / ".stestr.conf",
ROOT_DIR / ".jujuignore",
]
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
###############################################
# Utility functions
###############################################
@dataclasses.dataclass
class SunbeamBuild:
path: pathlib.Path
external_libraries: list[str]
internal_libraries: list[str]
templates: list[str]
@classmethod
def load(cls, path: pathlib.Path) -> "SunbeamBuild":
with path.open() as f:
data = yaml.safe_load(f)
return cls(
path=path.parent,
external_libraries=data.get("external-libraries", []),
internal_libraries=data.get("internal-libraries", []),
templates=data.get("templates", []),
)
def _library_to_path(library: str) -> pathlib.Path:
split = library.split(".")
if len(split) != 4:
raise ValueError(f"Invalid library: {library}")
return pathlib.Path("/".join(split) + ".py")
def validate_charm(
charm: str,
internal_libraries: dict[str, pathlib.Path],
external_libraries: dict[str, pathlib.Path],
templates: dict[str, pathlib.Path],
) -> SunbeamBuild:
"""Validate the charm."""
path = ROOT_DIR / "charms" / charm
if not path.exists():
raise ValueError(f"Charm {charm} does not exist.")
build_file = path / BUILD_FILE
if not build_file.exists():
raise ValueError(f"Charm {charm} does not have a build file.")
charm_build = load_charm(charm)
for library in charm_build.external_libraries:
if library not in external_libraries:
raise ValueError(
f"Charm {charm} has invalid external library: {library} not found."
)
for library in charm_build.internal_libraries:
if library not in internal_libraries:
raise ValueError(
f"Charm {charm} has invalid internal library: {library} not found."
)
for template in charm_build.templates:
if template not in templates:
raise ValueError(
f"Charm {charm} has invalid template: {template} not found."
)
return charm_build
def load_external_libraries() -> dict[str, pathlib.Path]:
"""Load the external libraries."""
path = EXTERNAL_LIB_DIR
return {
str(p.relative_to(path))[:-3].replace("/", "."): p
for p in path.glob("**/*.py")
}
def load_internal_libraries() -> dict[str, pathlib.Path]:
"""Load the internal libraries."""
charms = list((ROOT_DIR / "charms").iterdir())
libraries = {}
for charm in charms:
path = charm / "lib"
search_path = path / "charms" / charm.name.replace("-", "_")
libraries.update(
{
str(p.relative_to(path))[:-3].replace("/", "."): p
for p in search_path.glob("**/*.py")
}
)
return libraries
def load_templates() -> dict[str, pathlib.Path]:
"""Load the templates."""
path = ROOT_DIR / "templates"
return {str(p.relative_to(path)): p for p in path.glob("**/*")}
def list_charms() -> list[str]:
"""List the available charms."""
return [p.name for p in (ROOT_DIR / "charms").iterdir() if p.is_dir()]
def load_charm(charm: str) -> SunbeamBuild:
"""Load the charm build file."""
path = ROOT_DIR / "charms" / charm / BUILD_FILE
return SunbeamBuild.load(path)
def copy(src: pathlib.Path, dest: pathlib.Path):
"""Copy the src to dest.
Only supports files.
"""
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(src, dest)
def prepare_charm(
charm: SunbeamBuild,
internal_libraries: dict[str, pathlib.Path],
external_libraries: dict[str, pathlib.Path],
templates: dict[str, pathlib.Path],
dry_run: bool = False,
):
"""Copy the necessary files.
Will copy external libraries, ops sunbeam and templates.
"""
dest = charm.path / "lib" / "ops_sunbeam"
logger.debug(f"Copying ops sunbeam to {dest}")
if not dry_run:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(OPS_SUNBEAM_DIR, dest)
for utility_file in UTILITY_FILES:
utility_path = utility_file.relative_to(ROOT_DIR)
dest = charm.path / utility_path
logger.debug(f"Copying {utility_file} to {dest}")
if not dry_run:
copy(utility_file, dest)
for library in charm.external_libraries:
path = external_libraries[library]
library_path = path.relative_to(EXTERNAL_LIB_DIR)
dest = charm.path / "lib" / library_path
if not dest.exists():
logger.debug(f"Copying {library} to {dest}")
if dry_run:
continue
copy(path, dest)
for library in charm.internal_libraries:
path = internal_libraries[library]
library_path = _library_to_path(library)
dest = charm.path / "lib" / library_path
if not dest.exists():
logger.debug(f"Copying {library} to {dest}")
if dry_run:
continue
copy(path, dest)
for template in charm.templates:
path = templates[template]
dest = charm.path / "src" / "templates" / template
if not dest.exists():
logger.debug(f"Copying {template} to {dest}")
if dry_run:
continue
copy(path, dest)
def clean_charm(
charm: SunbeamBuild,
dry_run: bool = False,
):
"""Clean charm directory.
Will remove the external libraries, ops sunbeam and templates.
"""
path = charm.path / "lib" / "ops_sunbeam"
if path.exists():
logger.debug(f"Removing {path}")
if not dry_run:
shutil.rmtree(path)
for utility_file in UTILITY_FILES:
utility_path = utility_file.relative_to(ROOT_DIR)
path = charm.path / utility_path
if path.exists():
logger.debug(f"Removing {path}")
if not dry_run:
path.unlink()
for library in charm.external_libraries + charm.internal_libraries:
# Remove the charm namespace
path = charm.path / "lib" / _library_to_path(library).parents[1]
if path.exists():
logger.debug(f"Removing {path}")
if dry_run:
continue
shutil.rmtree(path)
for template in charm.templates:
path = charm.path / "src" / "templates" / template
if path.exists():
logger.debug(f"Removing {path}")
if dry_run:
continue
path.unlink()
###############################################
# Cli Definitions
###############################################
def _add_charm_argument(parser: argparse.ArgumentParser):
parser.add_argument(
"charm", type=str, nargs="*", help="The charm to operate on."
)
def main_cli():
main_parser = argparse.ArgumentParser(
description="Sunbeam Repository utilities."
)
main_parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose logging."
)
subparsers = main_parser.add_subparsers(
required=True, help="sub-command help"
)
prepare_parser = subparsers.add_parser("prepare", help="Prepare charm(s).")
_add_charm_argument(prepare_parser)
prepare_parser.add_argument(
"--clean",
action="store_true",
default=False,
help="Clean the charm(s) first.",
)
prepare_parser.add_argument(
"--dry-run", action="store_true", default=False, help="Dry run."
)
prepare_parser.set_defaults(func=prepare_cli)
clean_parser = subparsers.add_parser("clean", help="Clean charm(s).")
_add_charm_argument(clean_parser)
clean_parser.add_argument(
"--dry-run", action="store_true", default=False, help="Dry run."
)
clean_parser.set_defaults(func=clean_cli)
validate_parser = subparsers.add_parser(
"validate", help="Validate charm(s)."
)
_add_charm_argument(validate_parser)
validate_parser.set_defaults(func=validate_cli)
pythonpath_parser = subparsers.add_parser(
"pythonpath", help="Print the pythonpath."
)
pythonpath_parser.set_defaults(func=pythonpath_cli)
fetch_lib_parser = subparsers.add_parser(
"fetch-lib", help="Fetch the external libraries."
)
fetch_lib_parser.add_argument(
"libraries", type=str, nargs="*", help="Libraries to fetch."
)
fetch_lib_parser.set_defaults(func=fetch_lib_cli)
args = main_parser.parse_args()
level = logging.INFO
if args.verbose:
level = logging.DEBUG
logger.setLevel(level)
context = vars(args)
context["internal_libraries"] = load_internal_libraries()
context["external_libraries"] = load_external_libraries()
context["templates"] = load_templates()
context["sunbeam_charms"] = list_charms()
if "charm" in context:
charms = context.pop("charm")
if not charms:
charms = context["sunbeam_charms"]
context["charms"] = [
validate_charm(
charm,
context["internal_libraries"],
context["external_libraries"],
context["templates"],
)
for charm in charms
]
args.func(**context)
def prepare_cli(
charms: list[SunbeamBuild],
internal_libraries: dict[str, pathlib.Path],
external_libraries: dict[str, pathlib.Path],
templates: dict[str, pathlib.Path],
clean: bool = False,
dry_run: bool = False,
**kwargs,
):
for charm in charms:
logger.info("Preparing the charm %s", charm.path.name)
if clean:
clean_charm(charm, dry_run=dry_run)
prepare_charm(
charm,
internal_libraries,
external_libraries,
templates,
dry_run=dry_run,
)
def clean_cli(
charms: list[SunbeamBuild],
internal_libraries: dict[str, pathlib.Path],
external_libraries: dict[str, pathlib.Path],
templates: dict[str, pathlib.Path],
dry_run: bool = False,
**kwargs,
):
for charm in charms:
logger.info("Cleaning the charm %s", charm.path.name)
clean_charm(charm, dry_run=dry_run)
def validate_cli(
charms: list[SunbeamBuild],
internal_libraries: dict[str, pathlib.Path],
external_libraries: dict[str, pathlib.Path],
templates: dict[str, pathlib.Path],
**kwargs,
):
"""No op because done in the main_cli."""
for charm in charms:
logging.info("Charm %s is valid.", charm.path.name)
def pythonpath_cli(internal_libraries: dict[str, pathlib.Path], **kwargs):
"""Print the pythonpath."""
parent_dirs = set()
for path in internal_libraries.values():
parent_dirs.add(path.parents[3])
parent_dirs.add(OPS_SUNBEAM_DIR.parent)
parent_dirs.add(EXTERNAL_LIB_DIR)
print(":".join(str(p) for p in parent_dirs))
def fetch_lib_cli(
libraries: list[str], external_libraries: dict[str, pathlib.Path], **kwargs
):
"""Fetch the external libraries."""
cwd = EXTERNAL_LIB_DIR.parent
libraries_set = set(libraries)
unknown_libraries = libraries_set - set(external_libraries.keys())
if unknown_libraries:
raise ValueError(f"Unknown libraries: {unknown_libraries}")
if not libraries_set:
libraries_set = set(external_libraries.keys())
for library in libraries_set:
logging.info(f"Fetching {library}")
# Fetch the library
subprocess.run(
["charmcraft", "fetch-lib", library], cwd=cwd, check=True
)
if __name__ == "__main__":
main_cli()