Skip to content

Commit d6676b2

Browse files
misohudeusebio
authored andcommitted
fix: adjust the dry-run behavior and file parsing (#130)
1 parent 8803366 commit d6676b2

File tree

2 files changed

+102
-31
lines changed

2 files changed

+102
-31
lines changed

scripts/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Scripts
2+
3+
## `charms_promotions.py`
4+
5+
Promotes charm revisions from a Juju status export to a higher risk channel (`beta``candidate``stable`).
6+
7+
### What it reads
8+
9+
- `--format text`: a `juju status` text output (full output is fine, not just the apps table)
10+
- `--format yaml`: a status YAML with `applications.*.charm-name/charm-rev/charm-channel`
11+
12+
### Quick usage
13+
14+
From the `scripts/` directory:
15+
16+
```bash
17+
python charms_promotions.py --file status.txt --format text --promote-to candidate --dry-run
18+
```
19+
20+
Run for real:
21+
22+
```bash
23+
python charms_promotions.py --file status.txt --format text --promote-to candidate --apply
24+
```
25+
26+
Exclude charms (space-separated):
27+
28+
```bash
29+
python charms_promotions.py --file status.txt --format text --promote-to candidate --dry-run --exclude mysql-k8s
30+
```
31+
32+
### Notes
33+
34+
- Default mode is dry-run: it prints `charmcraft release ...` commands.
35+
- Dry-run still calls `charmcraft status` to resolve exact revisions/resources.
36+
- If you do not have access to a charm package, the script prints a warning and skips it.
37+
- Passing `--exclude` replaces the default exclude list.

scripts/charms_promotions.py

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import json
22
import re
3+
import shlex
34
import subprocess
5+
import sys
46
from dataclasses import dataclass
57
from pathlib import Path
68
from typing import Optional
79

810
import yaml
9-
from IPython.core.oinspect import Bundle
1011

1112
RISKS = {"edge": 1, "beta": 2, "candidate": 3, "stable": 4}
1213

@@ -29,7 +30,8 @@ def get_status(self, channel: Optional[str] = None, revision: Optional[int] = No
2930

3031
if not self._status:
3132
output = subprocess.check_output(
32-
["charmcraft", "status", self.name, "--format", "json"]
33+
["charmcraft", "status", self.name, "--format", "json"],
34+
stderr=subprocess.STDOUT,
3335
)
3436

3537
self._status = json.loads(output.decode("utf-8"))
@@ -80,7 +82,7 @@ def promote_version(self, risk: str, dry_run: bool = True):
8082
)
8183

8284
if dry_run:
83-
return cmds
85+
return shlex.join(cmds)
8486

8587
return subprocess.check_output(cmds).decode("utf-8")
8688

@@ -97,13 +99,14 @@ class Bundle:
9799
charms: list[Charm]
98100

99101
@classmethod
100-
def from_status(cls, content: str, format: Format = "text"):
102+
def from_status(cls, content: str, format: Format | str = Format.TEXT):
101103
parsers = {
102104
Format.TEXT: TextParser,
103105
Format.YAML: YAMLParser
104106
}
107+
normalized_format = Format(format)
105108

106-
return parsers[format].parse(content)
109+
return parsers[normalized_format].parse(content)
107110

108111

109112
class YAMLParser:
@@ -119,7 +122,7 @@ def parse(content: str):
119122

120123

121124
class TextParser:
122-
word_with_leading_spaces = re.compile("^\s*[^\s]+")
125+
word_with_leading_spaces = re.compile(r"^\s*[^\s]+")
123126

124127
@staticmethod
125128
def extract_first_word(mystring):
@@ -134,13 +137,35 @@ def parse_line(line, indices):
134137

135138
@staticmethod
136139
def parse(content: str):
140+
lines = content.splitlines()
141+
142+
white_spaces = re.compile(r"\s+\s+")
143+
144+
app_header_index = next(
145+
(
146+
index
147+
for index, line in enumerate(lines)
148+
if line.strip().startswith("App")
149+
and "Charm" in line
150+
and "Channel" in line
151+
),
152+
None,
153+
)
154+
155+
if app_header_index is None:
156+
raise ValueError("Could not locate applications table in status text")
137157

138-
lines = content.split("\n")
158+
table_lines = []
159+
for line in lines[app_header_index:]:
160+
if table_lines and (not line.strip() or line.strip().startswith("Unit")):
161+
break
162+
table_lines.append(line)
139163

140-
white_spaces = re.compile("\s+\s+")
164+
if len(table_lines) < 2:
165+
raise ValueError("Applications table is empty in status text")
141166

142167
# Get header
143-
header = lines[0]
168+
header = table_lines[0]
144169

145170
# First guess of width based on headers
146171
ends=[s.end() for s in white_spaces.finditer(header)]
@@ -153,7 +178,7 @@ def parse(content: str):
153178
# This is due to the fact that some columns the text extends to before the start
154179
# of the columns header (text aligned right)
155180
widths = [len(column) for column in columns]
156-
for line in lines[1:]:
181+
for line in table_lines[1:]:
157182

158183
widths = list(map(max,zip(
159184
widths,
@@ -167,10 +192,10 @@ def parse(content: str):
167192

168193
data = [
169194
dict(zip(columns, TextParser.parse_line(line, indices)))
170-
for line in lines[1:]
195+
for line in table_lines[1:]
196+
if line.strip()
171197
]
172198

173-
# pd.DataFrame([TextParser.parse_line(line, indices) for line in lines[1:]], columns= columns)
174199
return Bundle([
175200
Charm(item["Charm"], int(item["Rev"]), item["Channel"])
176201
for item in data
@@ -180,18 +205,25 @@ def parse(content: str):
180205

181206
if __name__ == "__main__":
182207

183-
# with open("./scripts/status.txt") as fid:
184-
# bundle = Bundle.from_status(fid.read(), Format.TEXT)
185-
186-
# with open("./scripts/status.yaml") as fid:
187-
# bundle = Bundle.from_status(fid.read(), Format.YAML)
188-
189208
import argparse
190209

191210
parser = argparse.ArgumentParser()
192211

193212
parser.add_argument("--file", required=True)
194-
parser.add_argument("--apply", default=False, action="store_true")
213+
action_group = parser.add_mutually_exclusive_group()
214+
action_group.add_argument(
215+
"--dry-run",
216+
dest="dry_run",
217+
action="store_true",
218+
help="Print release commands without executing them (default)",
219+
)
220+
action_group.add_argument(
221+
"--apply",
222+
dest="dry_run",
223+
action="store_false",
224+
help="Execute charmcraft release commands",
225+
)
226+
parser.set_defaults(dry_run=True)
195227
parser.add_argument("--format", choices=("text", "yaml"), default="text")
196228
parser.add_argument("--promote-to", choices=("beta", "candidate", "stable"), default="beta")
197229
parser.add_argument("--exclude", nargs="*", default=["mysql-k8s"])
@@ -202,15 +234,17 @@ def parse(content: str):
202234

203235
for charm in bundle.charms:
204236
if not charm.name in args.exclude:
205-
print(charm.promote_version(args.promote_to, not args.apply))
206-
207-
208-
209-
210-
211-
212-
213-
214-
215-
216-
237+
try:
238+
print(charm.promote_version(args.promote_to, args.dry_run))
239+
except subprocess.CalledProcessError as err:
240+
output = (err.output or b"").decode("utf-8", errors="replace").strip()
241+
if "permission-required" in output:
242+
print(
243+
f"WARNING: skipping '{charm.name}' due to missing permissions. Add it to --exclude to avoid this warning.",
244+
file=sys.stderr,
245+
)
246+
else:
247+
print(
248+
f"WARNING: skipping '{charm.name}' after command failure: {shlex.join(err.cmd)}",
249+
file=sys.stderr,
250+
)

0 commit comments

Comments
 (0)