diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eb373f4..5b713a3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,8 @@ repos: - id: mypy additional_dependencies: - "click>=8.0.3,!=8.1.4" + - "attrs>=20.3.0" + - "ruamel.yaml>=0.16.6" pass_filenames: false args: [--config-file=pyproject.toml] diff --git a/newa/__init__.py b/newa/__init__.py index 06ad3be..c92646c 100644 --- a/newa/__init__.py +++ b/newa/__init__.py @@ -1,6 +1,143 @@ -import click +import io +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, TypeVar +import attrs +import ruamel.yaml +import ruamel.yaml.nodes +import ruamel.yaml.representer +from attrs import define, field -@click.command() -def main() -> None: - print('Newa!') +if TYPE_CHECKING: + from typing_extensions import Self, TypeAlias + + ErratumId: TypeAlias = str + + +T = TypeVar('T') +SerializableT = TypeVar('SerializableT', bound='Serializable') + + +def yaml_parser() -> ruamel.yaml.YAML: + """ Create standardized YAML parser """ + + yaml = ruamel.yaml.YAML(typ='safe') + + yaml.indent(mapping=4, sequence=4, offset=2) + yaml.default_flow_style = False + yaml.allow_unicode = True + yaml.encoding = 'utf-8' + + # For simpler dumping of well-known classes + def _represent_enum( + representer: ruamel.yaml.representer.Representer, + data: Enum) -> ruamel.yaml.nodes.ScalarNode: + return representer.represent_scalar('tag:yaml.org,2002:str', data.value) + + yaml.representer.add_representer(EventType, _represent_enum) + + return yaml + + +class EventType(Enum): + """ Event types """ + + ERRATUM = 'erratum' + + +@define +class Cloneable: + """ A class whose instances can be cloned """ + + def clone(self) -> 'Self': + return attrs.evolve(self) + + +@define +class Serializable: + """ A class whose instances can be serialized into YAML """ + + def to_yaml(self) -> str: + output = io.StringIO() + + yaml_parser().dump(attrs.asdict(self, recurse=True), output) + + return output.getvalue() + + def to_yaml_file(self, filepath: Path) -> None: + filepath.write_text(self.to_yaml()) + + @classmethod + def from_yaml(cls: type[SerializableT], serialized: str) -> SerializableT: + data = yaml_parser().load(serialized) + + return cls(**data) + + @classmethod + def from_yaml_file(cls: type[SerializableT], filepath: Path) -> SerializableT: + return cls.from_yaml(filepath.read_text()) + + +@define +class Event(Serializable): + """ A triggering event of Newa pipeline """ + + type_: EventType = field(converter=EventType) + id: 'ErratumId' + + +@define +class InitialErratum(Serializable): + """ + An initial erratum as an input. + + It does not track releases, just the initial event. It will be expanded + into corresponding :py:class:`ErratumJob` instances. + """ + + event: Event = field( # type: ignore[var-annotated] + converter=lambda x: x if isinstance(x, Event) else Event(**x), + ) + + +@define +class Erratum(Cloneable, Serializable): + """ An eratum """ + + release: str + # builds: list[...] = ... + + def fetch_details(self) -> None: + raise NotImplementedError + + +@define +class Job(Cloneable, Serializable): + """ A single job """ + + event: Event = field( # type: ignore[var-annotated] + converter=lambda x: x if isinstance(x, Event) else Event(**x), + ) + + # issue: ... + # recipe: ... + # test_job: ... + # job_result: ... + + @property + def id(self) -> str: + raise NotImplementedError + + +@define +class ErratumJob(Job): + """ A single *erratum* job """ + + erratum: Erratum = field( # type: ignore[var-annotated] + converter=lambda x: x if isinstance(x, Erratum) else Erratum(**x), + ) + + @property + def id(self) -> str: + return f'{self.event.id} @ {self.erratum.release}' diff --git a/newa/cli.py b/newa/cli.py new file mode 100644 index 0000000..f4694a5 --- /dev/null +++ b/newa/cli.py @@ -0,0 +1,178 @@ +import logging +import os.path +from collections.abc import Iterable, Iterator +from pathlib import Path + +import click +from attrs import define + +from . import Erratum, ErratumJob, Event, EventType, InitialErratum + +logging.basicConfig( + format='%(asctime)s %(message)s', + datefmt='%m/%d/%Y %I:%M:%S %p', + level=logging.INFO) + + +@define +class CLIContext: + """ State information about one Newa pipeline invocation """ + + logger: logging.Logger + + # Path to directory with state files + state_dirpath: Path + + def enter_command(self, command: str) -> None: + self.logger.handlers[0].formatter = logging.Formatter( + f'[%(asctime)s] [{command.ljust(8, " ")}] %(message)s', + ) + + def load_initial_erratum(self, filepath: Path) -> InitialErratum: + erratum = InitialErratum.from_yaml_file(filepath) + + self.logger.info(f'Discovered initial erratum {erratum.event.id} in {filepath}') + + return erratum + + def load_initial_errata(self, filename_prefix: str) -> Iterator[InitialErratum]: + for child in self.state_dirpath.iterdir(): + if not child.name.startswith(filename_prefix): + continue + + yield self.load_initial_erratum(self.state_dirpath / child) + + def load_erratum_job(self, filepath: Path) -> ErratumJob: + job = ErratumJob.from_yaml_file(filepath) + + self.logger.info(f'Discovered erratum job {job.id} in {filepath}') + + return job + + def load_erratum_jobs(self, filename_prefix: str) -> Iterator[ErratumJob]: + for child in self.state_dirpath.iterdir(): + if not child.name.startswith(filename_prefix): + continue + + yield self.load_erratum_job(self.state_dirpath / child) + + def save_erratum_job(self, filename_prefix: str, job: ErratumJob) -> None: + filepath = self.state_dirpath / \ + f'{filename_prefix}{job.event.id}-{job.erratum.release}.yaml' + + job.to_yaml_file(filepath) + self.logger.info(f'Erratum job {job.id} written to {filepath}') + + def save_erratum_jobs(self, filename_prefix: str, jobs: Iterable[ErratumJob]) -> None: + for job in jobs: + self.save_erratum_job(filename_prefix, job) + + +@click.group(chain=True) +@click.option( + '--state-dir', + default='$PWD/state', + ) +@click.pass_context +def main(click_context: click.Context, state_dir: str) -> None: + ctx = CLIContext( + logger=logging.getLogger(), + state_dirpath=Path(os.path.expandvars(state_dir)), + ) + click_context.obj = ctx + + if not ctx.state_dirpath.exists(): + ctx.logger.info(f'State directory {ctx.state_dirpath} does not exist, creating...') + ctx.state_dirpath.mkdir(parents=True) + + +@main.command(name='event') +@click.option( + '-e', '--erratum', 'errata_ids', + multiple=True, + ) +@click.pass_obj +def cmd_event(ctx: CLIContext, errata_ids: tuple[str, ...]) -> None: + ctx.enter_command('event') + + if errata_ids: + for erratum_id in errata_ids: + event = Event(type_=EventType.ERRATUM, id=erratum_id) + + # fetch erratum details, namely releases + releases = ['RHEL-8.10.0', 'RHEL-9.4.0'] + + for release in releases: + erratum_job = ErratumJob(event=event, erratum=Erratum(release=release)) + + ctx.save_erratum_job('event-', erratum_job) + + else: + for erratum in ctx.load_initial_errata('init-'): + # fetch erratum details, namely releases + releases = ['RHEL-8.10.0', 'RHEL-9.4.0'] + + for release in releases: + erratum_job = ErratumJob(event=erratum.event, erratum=Erratum(release=release)) + + ctx.save_erratum_job('event-', erratum_job) + + +@main.command(name='jira') +@click.pass_obj +def cmd_jira(ctx: CLIContext) -> None: + ctx.enter_command('jira') + + for erratum_job in ctx.load_erratum_jobs('event-'): + # read Jira issue configuration + # get list of matching actions + + # for action in actions: + # create epic + # or create task + # or create sutask + # if subtask assoc. with recipes + # clone object with yaml + + # erratum_job.issue = ... + # what's recipe? doesn't it belong to "schedule"? + # recipe = new JobRecipe(url) + + ctx.save_erratum_job('jira-', erratum_job) + + +@main.command(name='schedule') +@click.pass_obj +def cmd_schedule(ctx: CLIContext) -> None: + ctx.enter_command('schedule') + + for erratum_job in ctx.load_erratum_jobs('jira-'): + # prepare parameters based on errata details (environment variables) + # generate all relevant test jobs using the recipe + # prepares a list of JobExec objects + + ctx.save_erratum_job('schedule-', erratum_job) + + +@main.command(name='execute') +@click.pass_obj +def cmd_execute(ctx: CLIContext) -> None: + ctx.enter_command('execute') + + for erratum_job in ctx.load_erratum_jobs('schedule-'): + # worker = new Executor(yaml) + # run() returns result object + # result = worker.run() + + ctx.save_erratum_job('execute-', erratum_job) + + +@main.command(name='report') +@click.pass_obj +def cmd_report(ctx: CLIContext) -> None: + ctx.enter_command('report') + + for _ in ctx.load_erratum_jobs('execute-'): + pass + # read yaml details + # update Jira issue with job result diff --git a/pyproject.toml b/pyproject.toml index 519516a..77e87a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,13 +37,15 @@ keywords = [ # "Operating System :: POSIX :: Linux", # ] dependencies = [ - "click>=8.0.3,!=8.1.4" + "click>=8.0.3,!=8.1.4", + "attrs>=20.3.0", + "ruamel.yaml>=0.16.6" ] [project.optional-dependencies] [project.scripts] -newa = "newa:main" +newa = "newa.cli:main" [project.urls] # TODO: provide URL