Skip to content

Commit

Permalink
Initial command and class skeleton (#6)
Browse files Browse the repository at this point in the history
* Initial command and class skeleton

* squash: make YAML safe

* sqush: move things around to create true container

* squash: switch erratum to one release only

* squash: undo single erratum
  • Loading branch information
happz authored Feb 16, 2024
1 parent 8142dc0 commit 244bb1d
Show file tree
Hide file tree
Showing 4 changed files with 325 additions and 6 deletions.
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
145 changes: 141 additions & 4 deletions newa/__init__.py
Original file line number Diff line number Diff line change
@@ -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}'
178 changes: 178 additions & 0 deletions newa/cli.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 244bb1d

Please sign in to comment.