Merge pull request #26929 from jankaluza/quadlet-docs

Rewrite the Quadlet documentation.
This commit is contained in:
openshift-merge-bot[bot]
2025-09-05 14:29:27 +00:00
committed by GitHub
137 changed files with 3018 additions and 2750 deletions

View File

@@ -23,6 +23,100 @@ class Preprocessor():
self.pod_or_container = ''
self.used_by = {}
def render(self, text: str, context: dict) -> str:
"""
Renders the `text` handling the following extra formatting features:
```
<< if variable >>
...
<< endif >>
<< if not variable >>
...
<< else >>
...
<< endif >>
<< "foo" if variable else "bar" >>
```
Returns the rendered text.
"""
# Match << ... >>
TOK = re.compile(r"<<(.*?)>>", re.DOTALL)
out = []
pos = 0
stack = [] # each frame: {"active": bool, "seen_else": bool}
def is_active():
return all(f["active"] for f in stack)
def get_variable(name: str):
v = context.get(name, None)
if v is None:
raise ValueError(f"undefined variable: {name}")
return v
def truthy(name: str) -> bool:
name = name.strip()
if name.startswith("not "):
v = get_variable(name[4:].strip())
return not bool(v)
return bool(get_variable(name))
for m in TOK.finditer(text):
# write literal up to token
literal = text[pos:m.start()]
if is_active():
out.append(literal)
pos = m.end()
inner = m.group(1).strip()
# control blocks
if inner.startswith("if ") and len(inner[3:].strip().split(" ")) in [1, 2]:
cond = inner[3:].strip()
stack.append({"active": is_active() and truthy(cond), "seen_else": False})
continue
if inner == "else":
if not stack:
raise ValueError("`else` without `if`")
frame = stack[-1]
if frame["seen_else"]:
raise ValueError("multiple `else` in the same `if`")
frame["seen_else"] = True
parent_active = all(f["active"] for f in stack[:-1])
frame["active"] = parent_active and not frame["active"]
continue
if inner == "endif":
if not stack:
raise ValueError("`end` without `if`")
stack.pop()
continue
# inline "X if cond else Y" ---
if " if " in inner and " else " in inner:
try:
# split by " if " then " else "
then_part, rest = inner.split(" if ", 1)
cond, else_part = rest.split(" else ", 1)
cond = cond.strip()
chosen = then_part if truthy(cond) else else_part
if is_active():
out.append(chosen.strip().strip("'\""))
except Exception as e:
raise ValueError(f"Invalid inline if/else syntax: {inner}") from e
continue
# trailing literal
if is_active():
out.append(text[pos:])
if stack:
raise ValueError("unclosed `if` block(s)")
return "".join(out)
def process(self, infile:str):
"""
Main calling point: preprocesses one file
@@ -40,18 +134,24 @@ class Preprocessor():
with open(infile, 'r', encoding='utf-8') as fh_in, open(outfile_tmp, 'w', encoding='utf-8', newline='\n') as fh_out:
for line in fh_in:
# '@@option foo' -> include file options/foo.md
if line.startswith('@@option '):
_, optionname = line.strip().split(" ")
optionfile = os.path.join("options", optionname + '.md')
self.track_optionfile(optionfile)
self.insert_file(fh_out, optionfile)
# '@@include relative-path/must-exist.md'
elif line.startswith('@@include '):
_, path = line.strip().split(" ")
self.insert_file(fh_out, path)
else:
fh_out.write(line)
try:
# '@@option foo' -> include file options/foo.md
if line.startswith('@@option '):
_, optionname = line.strip().split(" ")
is_quadlet = optionname.startswith("quadlet:")
if is_quadlet:
optionname = optionname[len("quadlet:"):]
optionfile = os.path.join("options", optionname + '.md')
self.track_optionfile(optionfile)
self.insert_file(fh_out, optionfile, is_quadlet)
# '@@include relative-path/must-exist.md'
elif line.startswith('@@include '):
_, path = line.strip().split(" ")
self.insert_file(fh_out, path)
else:
fh_out.write(line)
except Exception as ex:
raise Exception(f"Error while processing {infile} line '{line[:-1]}'") from ex
os.chmod(outfile_tmp, 0o444)
os.rename(outfile_tmp, outfile)
@@ -87,7 +187,7 @@ class Preprocessor():
else:
os.unlink(tmpfile)
def insert_file(self, fh_out, path: str):
def insert_file(self, fh_out, path: str, is_quadlet=False):
"""
Reads one option file, writes it out to the given output filehandle
"""
@@ -98,13 +198,15 @@ class Preprocessor():
# comment in its output.
fh_out.write("\n[//]: # (BEGIN included file " + path + ")\n")
with open(path, 'r', encoding='utf-8') as fh_included:
for opt_line in fh_included:
rendered = self.render(fh_included.read(), {"is_quadlet": is_quadlet})
for opt_line in rendered.splitlines():
if opt_line.startswith('####>'):
continue
opt_line = self.replace_type(opt_line)
opt_line = opt_line.replace('<<subcommand>>', self.podman_subcommand())
opt_line = opt_line.replace('<<fullsubcommand>>', self.podman_subcommand('full'))
fh_out.write(opt_line)
fh_out.write(opt_line + '\n')
fh_out.write("\n[//]: # (END included file " + path + ")\n")
def podman_subcommand(self, full=None) -> str:
@@ -117,6 +219,9 @@ class Preprocessor():
if not full:
if subcommand.startswith("podman-pod-"):
subcommand = subcommand[len("podman-pod-"):]
# For quadlet man-pages, the subcommand is simply the full manpage name.
if subcommand.endswith(".unit.5.md.in"):
return subcommand
if subcommand.startswith("podman-"):
subcommand = subcommand[len("podman-"):]
if subcommand.endswith(".1.md.in"):

View File

@@ -19,7 +19,16 @@ our $VERSION = '0.1';
# BEGIN user-customizable section
our $Go = 'pkg/systemd/quadlet/quadlet.go';
our $Doc = 'docs/source/markdown/podman-systemd.unit.5.md';
our @Docs = (
"docs/source/markdown/podman-build.unit.5.md",
"docs/source/markdown/podman-container.unit.5.md",
"docs/source/markdown/podman-kube.unit.5.md",
"docs/source/markdown/podman-network.unit.5.md",
"docs/source/markdown/podman-pod.unit.5.md",
"docs/source/markdown/podman-volume.unit.5.md",
"docs/source/markdown/podman-image.unit.5.md",
"docs/source/markdown/podman-quadlet.7.md",
);
# END user-customizable section
###############################################################################
@@ -35,7 +44,7 @@ $ME cross-checks quadlet documentation between the Go source[Go]
and the man page[MD].
[Go]: $Go
[MD]: $Doc
[MD]: @Docs
We check that:
@@ -95,7 +104,7 @@ sub main {
my $true_keys = read_go($Go);
# Read md file, compare against Truth
crossref_doc($Doc, $true_keys);
crossref_docs(\@Docs, $true_keys);
exit $errs;
}
@@ -141,19 +150,17 @@ sub read_go {
}
##################
# crossref_doc # Read the markdown page, cross-check against Truth
# crossref_docs # Read the markdown pages, cross-check against Truth
##################
sub crossref_doc {
my $path = shift; # in: path to .md file
sub crossref_docs {
my $paths_ref = shift; # in: array with paths to .md file
my $true_keys = shift; # in: AREF, list of keys from .go
open my $fh, '<', $path
or die "$ME: Cannot read $path: $!\n";;
my $unit = '';
my %documented;
my @found_in_table;
my @described;
my $read_first_table;
# Helper function: when done reading description blocks,
# make sure that there's one block for each key listed
@@ -166,85 +173,86 @@ sub crossref_doc {
}
};
# Main loop: read the docs line by line
while (my $line = <$fh>) {
chomp $line;
# foreach loop
foreach my $path (@$paths_ref) {
open my $fh, '<', $path
or die "$ME: Cannot read $path: $!\n";;
# New section, with its own '| table |' and '### Keyword blocks'
if ($line =~ /^##\s+(\S+)\s+(?:units|section)\s+\[(\S+)\]/) {
my $new_unit = $1;
$new_unit eq $2
or warn "$ME: $path:$.: inconsistent block names in '$line'\n";
my $new_unit = $path;
$crossref_against_table->();
$unit = $new_unit;
$crossref_against_table->();
# Reset, because each section has its own table & blocks
@found_in_table = ();
@described = ();
$read_first_table = 0;
$unit = $new_unit;
# Reset, because each section has its own table & blocks
@found_in_table = ();
@described = ();
next;
}
# Main loop: read the docs line by line
while (my $line = <$fh>) {
chomp $line;
# Table line
if ($line =~ s/^\|\s+//) {
next if $line =~ /^\*\*/; # title
next if $line =~ /^-----/; # divider
# Table line
if ($read_first_table == 0 && $line =~ s/^\|\s+//) {
next if $line =~ /^\*\*/; # title
next if $line =~ /^-----/; # divider
if ($line =~ /^([A-Z][A-Za-z6]+)=/) {
if ($line =~ /^([A-Z][A-Za-z6]+)=/) {
my $key = $1;
grep { $_ eq $key } @$true_keys
or warn "$ME: $path:$.: unknown key '$key' (not present in $Go)\n";
# Sorting check
if (@found_in_table) {
if (lc($key) lt lc($found_in_table[-1])) {
warn "$ME: $path:$.: out-of-order key '$key' in table\n";
}
}
push @found_in_table, $key;
$documented{$key}++;
}
else {
warn "$ME: $path:$.: cannot grok table line '$line'\n";
}
}
# Description block
elsif ($line =~ /^###\s+`([A-Z][A-Za-z6]+)=.*`/) {
my $key = $1;
grep { $_ eq $key } @$true_keys
or warn "$ME: $path:$.: unknown key '$key' (not present in $Go)\n";
$read_first_table = 1;
# Sorting check
if (@found_in_table) {
if (lc($key) lt lc($found_in_table[-1])) {
warn "$ME: $path:$.: out-of-order key '$key' in table\n";
# Check for dups and for out-of-order
if (@described) {
if (lc($key) lt lc($described[-1])) {
warn "$ME: $path:$.: out-of-order key '$key'\n";
}
if (grep { lc($_) eq lc($key) } @described) {
warn "$ME: $path:$.: duplicate key '$key'\n";
}
}
push @found_in_table, $key;
grep { $_ eq $key } @found_in_table
or warn "$ME: $path:$.: key '$key' is not listed in table for unit/section '$unit'\n";
push @described, $key;
$documented{$key}++;
}
else {
warn "$ME: $path:$.: cannot grok table line '$line'\n";
}
}
# Description block
elsif ($line =~ /^###\s+`(\S+)=`/) {
my $key = $1;
# Check for dups and for out-of-order
if (@described) {
if (lc($key) lt lc($described[-1])) {
warn "$ME: $path:$.: out-of-order key '$key'\n";
}
if (grep { lc($_) eq lc($key) } @described) {
warn "$ME: $path:$.: duplicate key '$key'\n";
}
}
grep { $_ eq $key } @found_in_table
or warn "$ME: $path:$.: key '$key' is not listed in table for unit/section '$unit'\n";
push @described, $key;
$documented{$key}++;
}
close $fh;
}
close $fh;
# Final cross-check between table and description blocks
$crossref_against_table->();
# Check that no Go keys are missing
(my $md_basename = $path) =~ s|^.*/||;
for my $k (@$true_keys) {
$documented{$k}
or warn "$ME: undocumented key: '$k' not found anywhere in $md_basename\n";
or warn "$ME: undocumented key: '$k' not found anywhere in @$paths_ref\n";
}
}