Skip to content

Commit 979c7df

Browse files
phlogistonjohnmergify[bot]
authored andcommitted
commands: add generalized skips feature for sambacc command line
Add a generic lib for skipping the execution of a sambacc command based on file presence or environment variable contents. There's also an 'always:' rule for completeness & testing purposes. Signed-off-by: John Mulligan <[email protected]>
1 parent c0b1582 commit 979c7df

File tree

1 file changed

+187
-0
lines changed

1 file changed

+187
-0
lines changed

sambacc/commands/skips.py

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
#
2+
# sambacc: a samba container configuration tool
3+
# Copyright (C) 2024 John Mulligan
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU General Public License
16+
# along with this program. If not, see <http://www.gnu.org/licenses/>
17+
#
18+
19+
from typing import Optional
20+
import argparse
21+
import os
22+
23+
from sambacc.typelets import Self
24+
25+
from .cli import Context
26+
27+
28+
class SkipIf:
29+
"""Base class for objects used to check if a particular sambacc command
30+
should be skipped.
31+
Skips are useful when different commands are chained together
32+
unconditionally in a configuration file (like k8s init containers) but
33+
certain commmands should not be run.
34+
"""
35+
36+
NAME: str = ""
37+
38+
def test(self, ctx: Context) -> Optional[str]:
39+
"""Return a string explaining the reason for the skip or None
40+
indicating no skip is desired.
41+
"""
42+
raise NotImplementedError() # pragma: nocover
43+
44+
@classmethod
45+
def parse(cls, value: str) -> Self:
46+
"""Parse a string into a skip class arguments."""
47+
raise NotImplementedError() # pragma: nocover
48+
49+
50+
class SkipFile(SkipIf):
51+
"""Skip execution if a file exists or does not exist.
52+
The input value "file:/foo/bar" will trigger a skip if the file /foo/bar
53+
exists. To skip if a file does not exist, use "file:!/foo/bar" - prefix the
54+
file name with an exclaimation point.
55+
"""
56+
57+
NAME: str = "file"
58+
inverted: bool = False
59+
path: str = ""
60+
61+
@classmethod
62+
def parse(cls, value: str) -> Self:
63+
obj = cls()
64+
if not value:
65+
raise ValueError("missing path")
66+
if value[0] == "!":
67+
obj.inverted = True
68+
value = value[1:]
69+
obj.path = value
70+
return obj
71+
72+
def test(self, ctx: Context) -> Optional[str]:
73+
exists = os.path.exists(self.path)
74+
if self.inverted and not exists:
75+
return f"skip-if-file-missing: {self.path} missing"
76+
if not self.inverted and exists:
77+
return f"skip-if-file-exists: {self.path} exists"
78+
return None
79+
80+
81+
class SkipEnv(SkipIf):
82+
"""Skip execution if an environment variable is, or is not, equal to a
83+
value. The specification is roughly "env:<ENV_VAR><op><VALUE>" where op may
84+
be either `==` or `!=`. For example, "env:FLAVOR==cherry" will skip
85+
execution if the environment variable "FLAVOR" contains the value "cherry".
86+
"env:FLAVOR!=cherry" will skip execution if "FLAVOR" contains any value
87+
other than "cherry".
88+
"""
89+
90+
NAME: str = "env"
91+
_EQ = "=="
92+
_NEQ = "!="
93+
94+
def __init__(self, op: str, var_name: str, value: str) -> None:
95+
self.op = op
96+
self.var_name = var_name
97+
self.target_value = value
98+
99+
@classmethod
100+
def parse(cls, value: str) -> Self:
101+
if cls._EQ in value:
102+
op = cls._EQ
103+
elif cls._NEQ in value:
104+
op = cls._NEQ
105+
else:
106+
raise ValueError("invalid SkipEnv: missing or invalid operation")
107+
lhv, rhv = value.split(op, 1)
108+
return cls(op, lhv, rhv)
109+
110+
def test(self, ctx: Context) -> Optional[str]:
111+
env_val = os.environ.get(self.var_name)
112+
if self.op == self._EQ and env_val == self.target_value:
113+
return (
114+
f"env var: {self.var_name}"
115+
f" -> {env_val} {self.op} {self.target_value}"
116+
)
117+
if self.op == self._NEQ and env_val != self.target_value:
118+
return (
119+
f"env var: {self.var_name}"
120+
f" -> {env_val} {self.op} {self.target_value}"
121+
)
122+
return None
123+
124+
125+
class SkipAlways(SkipIf):
126+
"""Skip execution unconditionally. Must be specified as "always:" and takes
127+
no value after the colon.
128+
"""
129+
130+
NAME: str = "always"
131+
132+
@classmethod
133+
def parse(cls, value: str) -> Self:
134+
if value:
135+
raise ValueError("always skip takes no value")
136+
return cls()
137+
138+
def test(self, ctx: Context) -> Optional[str]:
139+
return "always skip"
140+
141+
142+
_SKIP_TYPES = [SkipFile, SkipEnv, SkipAlways]
143+
144+
145+
def test(
146+
ctx: Context, *, conditions: Optional[list[SkipIf]] = None
147+
) -> Optional[str]:
148+
"""Return a string explaining the reason for a skip or None indicating
149+
no skip should be performed. Typically the skip conditions will be
150+
derived from the command line arguments but can be passed in manually
151+
using the `conditions` keyword argument.
152+
"""
153+
if not conditions:
154+
conditions = ctx.cli.skip_conditions or []
155+
for cond in conditions:
156+
skip = cond.test(ctx)
157+
if skip:
158+
return skip
159+
return None
160+
161+
162+
def parse(value: str) -> SkipIf:
163+
"""Given a string return a SkipIf-based object. Every value must be
164+
prefixed with the skip "type" (the skip type's NAME).
165+
"""
166+
if value == "?":
167+
# A hack to avoid putting tons of documentation into the help output.
168+
raise argparse.ArgumentTypeError(_help_info())
169+
for sk in _SKIP_TYPES:
170+
assert issubclass(sk, SkipIf)
171+
prefix = f"{sk.NAME}:"
172+
plen = len(prefix)
173+
if value.startswith(prefix):
174+
return sk.parse(value[plen:])
175+
raise KeyError("no matching skip rule for: {value!r}")
176+
177+
178+
def _help_info() -> str:
179+
msgs = ["Skip conditions help details:", ""]
180+
for sk in _SKIP_TYPES:
181+
assert issubclass(sk, SkipIf)
182+
msgs.append(f"== Skip execution on condition `{sk.NAME}` ==")
183+
assert sk.__doc__
184+
for line in sk.__doc__.splitlines():
185+
msgs.append(line.strip())
186+
msgs.append("")
187+
return "\n".join(msgs)

0 commit comments

Comments
 (0)