Skip to content

Commit 244bb1d

Browse files
authored
Initial command and class skeleton (#6)
* 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
1 parent 8142dc0 commit 244bb1d

File tree

4 files changed

+325
-6
lines changed

4 files changed

+325
-6
lines changed

.pre-commit-config.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ repos:
2121
- id: mypy
2222
additional_dependencies:
2323
- "click>=8.0.3,!=8.1.4"
24+
- "attrs>=20.3.0"
25+
- "ruamel.yaml>=0.16.6"
2426
pass_filenames: false
2527
args: [--config-file=pyproject.toml]
2628

newa/__init__.py

+141-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,143 @@
1-
import click
1+
import io
2+
from enum import Enum
3+
from pathlib import Path
4+
from typing import TYPE_CHECKING, TypeVar
25

6+
import attrs
7+
import ruamel.yaml
8+
import ruamel.yaml.nodes
9+
import ruamel.yaml.representer
10+
from attrs import define, field
311

4-
@click.command()
5-
def main() -> None:
6-
print('Newa!')
12+
if TYPE_CHECKING:
13+
from typing_extensions import Self, TypeAlias
14+
15+
ErratumId: TypeAlias = str
16+
17+
18+
T = TypeVar('T')
19+
SerializableT = TypeVar('SerializableT', bound='Serializable')
20+
21+
22+
def yaml_parser() -> ruamel.yaml.YAML:
23+
""" Create standardized YAML parser """
24+
25+
yaml = ruamel.yaml.YAML(typ='safe')
26+
27+
yaml.indent(mapping=4, sequence=4, offset=2)
28+
yaml.default_flow_style = False
29+
yaml.allow_unicode = True
30+
yaml.encoding = 'utf-8'
31+
32+
# For simpler dumping of well-known classes
33+
def _represent_enum(
34+
representer: ruamel.yaml.representer.Representer,
35+
data: Enum) -> ruamel.yaml.nodes.ScalarNode:
36+
return representer.represent_scalar('tag:yaml.org,2002:str', data.value)
37+
38+
yaml.representer.add_representer(EventType, _represent_enum)
39+
40+
return yaml
41+
42+
43+
class EventType(Enum):
44+
""" Event types """
45+
46+
ERRATUM = 'erratum'
47+
48+
49+
@define
50+
class Cloneable:
51+
""" A class whose instances can be cloned """
52+
53+
def clone(self) -> 'Self':
54+
return attrs.evolve(self)
55+
56+
57+
@define
58+
class Serializable:
59+
""" A class whose instances can be serialized into YAML """
60+
61+
def to_yaml(self) -> str:
62+
output = io.StringIO()
63+
64+
yaml_parser().dump(attrs.asdict(self, recurse=True), output)
65+
66+
return output.getvalue()
67+
68+
def to_yaml_file(self, filepath: Path) -> None:
69+
filepath.write_text(self.to_yaml())
70+
71+
@classmethod
72+
def from_yaml(cls: type[SerializableT], serialized: str) -> SerializableT:
73+
data = yaml_parser().load(serialized)
74+
75+
return cls(**data)
76+
77+
@classmethod
78+
def from_yaml_file(cls: type[SerializableT], filepath: Path) -> SerializableT:
79+
return cls.from_yaml(filepath.read_text())
80+
81+
82+
@define
83+
class Event(Serializable):
84+
""" A triggering event of Newa pipeline """
85+
86+
type_: EventType = field(converter=EventType)
87+
id: 'ErratumId'
88+
89+
90+
@define
91+
class InitialErratum(Serializable):
92+
"""
93+
An initial erratum as an input.
94+
95+
It does not track releases, just the initial event. It will be expanded
96+
into corresponding :py:class:`ErratumJob` instances.
97+
"""
98+
99+
event: Event = field( # type: ignore[var-annotated]
100+
converter=lambda x: x if isinstance(x, Event) else Event(**x),
101+
)
102+
103+
104+
@define
105+
class Erratum(Cloneable, Serializable):
106+
""" An eratum """
107+
108+
release: str
109+
# builds: list[...] = ...
110+
111+
def fetch_details(self) -> None:
112+
raise NotImplementedError
113+
114+
115+
@define
116+
class Job(Cloneable, Serializable):
117+
""" A single job """
118+
119+
event: Event = field( # type: ignore[var-annotated]
120+
converter=lambda x: x if isinstance(x, Event) else Event(**x),
121+
)
122+
123+
# issue: ...
124+
# recipe: ...
125+
# test_job: ...
126+
# job_result: ...
127+
128+
@property
129+
def id(self) -> str:
130+
raise NotImplementedError
131+
132+
133+
@define
134+
class ErratumJob(Job):
135+
""" A single *erratum* job """
136+
137+
erratum: Erratum = field( # type: ignore[var-annotated]
138+
converter=lambda x: x if isinstance(x, Erratum) else Erratum(**x),
139+
)
140+
141+
@property
142+
def id(self) -> str:
143+
return f'{self.event.id} @ {self.erratum.release}'

newa/cli.py

+178
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import logging
2+
import os.path
3+
from collections.abc import Iterable, Iterator
4+
from pathlib import Path
5+
6+
import click
7+
from attrs import define
8+
9+
from . import Erratum, ErratumJob, Event, EventType, InitialErratum
10+
11+
logging.basicConfig(
12+
format='%(asctime)s %(message)s',
13+
datefmt='%m/%d/%Y %I:%M:%S %p',
14+
level=logging.INFO)
15+
16+
17+
@define
18+
class CLIContext:
19+
""" State information about one Newa pipeline invocation """
20+
21+
logger: logging.Logger
22+
23+
# Path to directory with state files
24+
state_dirpath: Path
25+
26+
def enter_command(self, command: str) -> None:
27+
self.logger.handlers[0].formatter = logging.Formatter(
28+
f'[%(asctime)s] [{command.ljust(8, " ")}] %(message)s',
29+
)
30+
31+
def load_initial_erratum(self, filepath: Path) -> InitialErratum:
32+
erratum = InitialErratum.from_yaml_file(filepath)
33+
34+
self.logger.info(f'Discovered initial erratum {erratum.event.id} in {filepath}')
35+
36+
return erratum
37+
38+
def load_initial_errata(self, filename_prefix: str) -> Iterator[InitialErratum]:
39+
for child in self.state_dirpath.iterdir():
40+
if not child.name.startswith(filename_prefix):
41+
continue
42+
43+
yield self.load_initial_erratum(self.state_dirpath / child)
44+
45+
def load_erratum_job(self, filepath: Path) -> ErratumJob:
46+
job = ErratumJob.from_yaml_file(filepath)
47+
48+
self.logger.info(f'Discovered erratum job {job.id} in {filepath}')
49+
50+
return job
51+
52+
def load_erratum_jobs(self, filename_prefix: str) -> Iterator[ErratumJob]:
53+
for child in self.state_dirpath.iterdir():
54+
if not child.name.startswith(filename_prefix):
55+
continue
56+
57+
yield self.load_erratum_job(self.state_dirpath / child)
58+
59+
def save_erratum_job(self, filename_prefix: str, job: ErratumJob) -> None:
60+
filepath = self.state_dirpath / \
61+
f'{filename_prefix}{job.event.id}-{job.erratum.release}.yaml'
62+
63+
job.to_yaml_file(filepath)
64+
self.logger.info(f'Erratum job {job.id} written to {filepath}')
65+
66+
def save_erratum_jobs(self, filename_prefix: str, jobs: Iterable[ErratumJob]) -> None:
67+
for job in jobs:
68+
self.save_erratum_job(filename_prefix, job)
69+
70+
71+
@click.group(chain=True)
72+
@click.option(
73+
'--state-dir',
74+
default='$PWD/state',
75+
)
76+
@click.pass_context
77+
def main(click_context: click.Context, state_dir: str) -> None:
78+
ctx = CLIContext(
79+
logger=logging.getLogger(),
80+
state_dirpath=Path(os.path.expandvars(state_dir)),
81+
)
82+
click_context.obj = ctx
83+
84+
if not ctx.state_dirpath.exists():
85+
ctx.logger.info(f'State directory {ctx.state_dirpath} does not exist, creating...')
86+
ctx.state_dirpath.mkdir(parents=True)
87+
88+
89+
@main.command(name='event')
90+
@click.option(
91+
'-e', '--erratum', 'errata_ids',
92+
multiple=True,
93+
)
94+
@click.pass_obj
95+
def cmd_event(ctx: CLIContext, errata_ids: tuple[str, ...]) -> None:
96+
ctx.enter_command('event')
97+
98+
if errata_ids:
99+
for erratum_id in errata_ids:
100+
event = Event(type_=EventType.ERRATUM, id=erratum_id)
101+
102+
# fetch erratum details, namely releases
103+
releases = ['RHEL-8.10.0', 'RHEL-9.4.0']
104+
105+
for release in releases:
106+
erratum_job = ErratumJob(event=event, erratum=Erratum(release=release))
107+
108+
ctx.save_erratum_job('event-', erratum_job)
109+
110+
else:
111+
for erratum in ctx.load_initial_errata('init-'):
112+
# fetch erratum details, namely releases
113+
releases = ['RHEL-8.10.0', 'RHEL-9.4.0']
114+
115+
for release in releases:
116+
erratum_job = ErratumJob(event=erratum.event, erratum=Erratum(release=release))
117+
118+
ctx.save_erratum_job('event-', erratum_job)
119+
120+
121+
@main.command(name='jira')
122+
@click.pass_obj
123+
def cmd_jira(ctx: CLIContext) -> None:
124+
ctx.enter_command('jira')
125+
126+
for erratum_job in ctx.load_erratum_jobs('event-'):
127+
# read Jira issue configuration
128+
# get list of matching actions
129+
130+
# for action in actions:
131+
# create epic
132+
# or create task
133+
# or create sutask
134+
# if subtask assoc. with recipes
135+
# clone object with yaml
136+
137+
# erratum_job.issue = ...
138+
# what's recipe? doesn't it belong to "schedule"?
139+
# recipe = new JobRecipe(url)
140+
141+
ctx.save_erratum_job('jira-', erratum_job)
142+
143+
144+
@main.command(name='schedule')
145+
@click.pass_obj
146+
def cmd_schedule(ctx: CLIContext) -> None:
147+
ctx.enter_command('schedule')
148+
149+
for erratum_job in ctx.load_erratum_jobs('jira-'):
150+
# prepare parameters based on errata details (environment variables)
151+
# generate all relevant test jobs using the recipe
152+
# prepares a list of JobExec objects
153+
154+
ctx.save_erratum_job('schedule-', erratum_job)
155+
156+
157+
@main.command(name='execute')
158+
@click.pass_obj
159+
def cmd_execute(ctx: CLIContext) -> None:
160+
ctx.enter_command('execute')
161+
162+
for erratum_job in ctx.load_erratum_jobs('schedule-'):
163+
# worker = new Executor(yaml)
164+
# run() returns result object
165+
# result = worker.run()
166+
167+
ctx.save_erratum_job('execute-', erratum_job)
168+
169+
170+
@main.command(name='report')
171+
@click.pass_obj
172+
def cmd_report(ctx: CLIContext) -> None:
173+
ctx.enter_command('report')
174+
175+
for _ in ctx.load_erratum_jobs('execute-'):
176+
pass
177+
# read yaml details
178+
# update Jira issue with job result

pyproject.toml

+4-2
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ keywords = [
3737
# "Operating System :: POSIX :: Linux",
3838
# ]
3939
dependencies = [
40-
"click>=8.0.3,!=8.1.4"
40+
"click>=8.0.3,!=8.1.4",
41+
"attrs>=20.3.0",
42+
"ruamel.yaml>=0.16.6"
4143
]
4244

4345
[project.optional-dependencies]
4446

4547
[project.scripts]
46-
newa = "newa:main"
48+
newa = "newa.cli:main"
4749

4850
[project.urls]
4951
# TODO: provide URL

0 commit comments

Comments
 (0)