Skip to content

Commit 88643c6

Browse files
committed
facts: add deb822 format support for APT sources
Add support for parsing deb822 format (.sources) files alongside traditional .list format APT source files. Features: - Parse deb822 stanzas with Types/URIs/Suites/Components fields - Map deb822 specific fields (Architectures, Signed-By) to legacy options - Maintain backwards compatibility with existing parse_apt_repo output - Support multiple types/URIs/suites combinations per stanza This is part 2/3 of modernizing APT source management.
1 parent 8d3ea39 commit 88643c6

File tree

4 files changed

+331
-34
lines changed

4 files changed

+331
-34
lines changed

src/pyinfra/facts/apt.py

Lines changed: 275 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,206 @@
11
from __future__ import annotations
22

33
import re
4+
from dataclasses import dataclass
5+
from typing import Union
46

57
from typing_extensions import TypedDict, override
68

79
from pyinfra.api import FactBase
810

911
from .gpg import GpgFactBase
10-
from .util import make_cat_files_command
12+
13+
14+
@dataclass
15+
class AptRepo:
16+
"""Represents an APT repository configuration.
17+
18+
This dataclass provides type safety for APT repository definitions,
19+
supporting both legacy .list style and modern deb822 .sources formats.
20+
21+
Provides dict-like access for backward compatibility while offering
22+
full type safety for modern code.
23+
"""
24+
25+
type: str # "deb" or "deb-src"
26+
url: str # Repository URL
27+
distribution: str # Suite/distribution name
28+
components: list[str] # List of components (e.g., ["main", "contrib"])
29+
options: dict[str, Union[str, list[str]]] # Repository options
30+
31+
# Dict-like interface for backward compatibility
32+
def __getitem__(self, key: str):
33+
"""Dict-like access: repo['type'] works like repo.type"""
34+
return getattr(self, key)
35+
36+
def __setitem__(self, key: str, value):
37+
"""Dict-like assignment: repo['type'] = 'deb' works like repo.type = 'deb'"""
38+
setattr(self, key, value)
39+
40+
def __contains__(self, key: str) -> bool:
41+
"""Support 'key' in repo syntax"""
42+
return hasattr(self, key)
43+
44+
def get(self, key: str, default=None):
45+
"""Dict-like get: repo.get('type', 'deb')"""
46+
return getattr(self, key, default)
47+
48+
def keys(self):
49+
"""Return dict-like keys"""
50+
return ["type", "url", "distribution", "components", "options"]
51+
52+
def values(self):
53+
"""Return dict-like values"""
54+
return [self.type, self.url, self.distribution, self.components, self.options]
55+
56+
def items(self):
57+
"""Return dict-like items"""
58+
return [(k, getattr(self, k)) for k in self.keys()]
59+
60+
@override
61+
def __eq__(self, other) -> bool:
62+
"""Enhanced equality that works with dicts and AptRepo instances"""
63+
if isinstance(other, dict):
64+
return (
65+
self.type == other.get("type")
66+
and self.url == other.get("url")
67+
and self.distribution == other.get("distribution")
68+
and self.components == other.get("components")
69+
and self.options == other.get("options")
70+
)
71+
elif isinstance(other, AptRepo):
72+
return (
73+
self.type == other.type
74+
and self.url == other.url
75+
and self.distribution == other.distribution
76+
and self.components == other.components
77+
and self.options == other.options
78+
)
79+
return False
80+
81+
def to_json(self):
82+
"""Convert to dict for JSON serialization"""
83+
return {
84+
"type": self.type,
85+
"url": self.url,
86+
"distribution": self.distribution,
87+
"components": self.components,
88+
"options": self.options,
89+
}
90+
91+
92+
@dataclass
93+
class AptSourcesFile:
94+
"""Represents a deb822 sources file entry before expansion into individual repositories.
95+
96+
This preserves the original multi-value fields from deb822 format,
97+
while AptRepo represents individual expanded repositories.
98+
"""
99+
100+
types: list[str] # ["deb", "deb-src"]
101+
uris: list[str] # ["http://deb.debian.org", "https://mirror.example.com"]
102+
suites: list[str] # ["bookworm", "bullseye"]
103+
components: list[str] # ["main", "contrib", "non-free"]
104+
architectures: list[str] | None = None # ["amd64", "i386"]
105+
signed_by: list[str] | None = None # ["/path/to/key1.gpg", "/path/to/key2.gpg"]
106+
trusted: str | None = None # "yes"/"no"
107+
108+
@classmethod
109+
def from_deb822_lines(cls, lines: list[str]) -> "AptSourcesFile | None":
110+
"""Parse deb822 stanza lines into AptSourcesFile.
111+
112+
Returns None if parsing failed or repository is disabled.
113+
"""
114+
if not lines:
115+
return None
116+
117+
data: dict[str, str] = {}
118+
for line in lines:
119+
if not line or line.startswith("#"):
120+
continue
121+
# Field-Name: value
122+
try:
123+
key, value = line.split(":", 1)
124+
except ValueError: # malformed line
125+
continue
126+
data[key.strip()] = value.strip()
127+
128+
# Validate required fields
129+
required = ("Types", "URIs", "Suites")
130+
if not all(field in data for field in required):
131+
return None
132+
133+
# Filter out disabled repositories
134+
enabled_str = data.get("Enabled", "yes").lower()
135+
if enabled_str != "yes":
136+
return None
137+
138+
# Parse fields into appropriate types
139+
return cls(
140+
types=data.get("Types", "").split(),
141+
uris=data.get("URIs", "").split(),
142+
suites=data.get("Suites", "").split(),
143+
components=data.get("Components", "").split(),
144+
architectures=(
145+
data.get("Architectures", "").split() if data.get("Architectures") else None
146+
),
147+
signed_by=data.get("Signed-By", "").split() if data.get("Signed-By") else None,
148+
trusted=data.get("Trusted", "").lower() if data.get("Trusted") else None,
149+
)
150+
151+
@classmethod
152+
def parse_sources_file(cls, lines: list[str]) -> list[AptRepo]:
153+
"""Parse a full deb822 .sources file into AptRepo instances.
154+
155+
Splits on blank lines into stanzas and parses each one.
156+
Returns a combined list of AptRepo instances for all stanzas.
157+
158+
Args:
159+
lines: Lines from a .sources file
160+
"""
161+
repos = []
162+
stanza: list[str] = []
163+
for raw in lines + [""]: # sentinel blank line to flush last stanza
164+
line = raw.rstrip("\n")
165+
if line.strip() == "":
166+
if stanza:
167+
sources_file = cls.from_deb822_lines(stanza)
168+
if sources_file:
169+
repos.extend(sources_file.expand_to_repos())
170+
stanza = []
171+
continue
172+
stanza.append(line)
173+
return repos
174+
175+
def expand_to_repos(self) -> list[AptRepo]:
176+
"""Expand this sources file entry into individual AptRepo instances."""
177+
# Build options dict in the same format as legacy parsing
178+
options: dict[str, Union[str, list[str]]] = {}
179+
180+
if self.architectures:
181+
options["arch"] = (
182+
self.architectures if len(self.architectures) > 1 else self.architectures[0]
183+
)
184+
if self.signed_by:
185+
options["signed-by"] = self.signed_by if len(self.signed_by) > 1 else self.signed_by[0]
186+
if self.trusted:
187+
options["trusted"] = self.trusted
188+
189+
repos = []
190+
# Produce combinations – in most real-world cases these will each be one.
191+
for repo_type in self.types:
192+
for uri in self.uris:
193+
for suite in self.suites:
194+
repos.append(
195+
AptRepo(
196+
type=repo_type,
197+
url=uri,
198+
distribution=suite,
199+
components=self.components.copy(), # copy to avoid shared reference
200+
options=dict(options), # copy per entry
201+
)
202+
)
203+
return repos
11204

12205

13206
def noninteractive_apt(command: str, force=False):
@@ -32,13 +225,21 @@ def noninteractive_apt(command: str, force=False):
32225
)
33226

34227

35-
def parse_apt_repo(name):
228+
def parse_apt_repo(name: str) -> AptRepo | None:
229+
"""Parse a traditional apt source line into an AptRepo.
230+
231+
Args:
232+
name: Apt source line (e.g., "deb [arch=amd64] http://example.com focal main")
233+
234+
Returns:
235+
AptRepo instance or None if parsing failed
236+
"""
36237
regex = r"^(deb(?:-src)?)(?:\s+\[([^\]]+)\])?\s+([^\s]+)\s+([^\s]+)\s+([a-z-\s\d]*)$"
37238

38239
matches = re.match(regex, name)
39240

40241
if not matches:
41-
return
242+
return None
42243

43244
# Parse any options
44245
options = {}
@@ -51,53 +252,97 @@ def parse_apt_repo(name):
51252

52253
options[key] = value
53254

54-
return {
55-
"options": options,
56-
"type": matches.group(1),
57-
"url": matches.group(3),
58-
"distribution": matches.group(4),
59-
"components": list(matches.group(5).split()),
60-
}
255+
return AptRepo(
256+
type=matches.group(1),
257+
url=matches.group(3),
258+
distribution=matches.group(4),
259+
components=list(matches.group(5).split()),
260+
options=options,
261+
)
61262

62263

63-
class AptSources(FactBase):
264+
def parse_apt_list_file(lines: list[str]) -> list[AptRepo]:
265+
"""Parse legacy .list style apt source file.
266+
267+
Each non-comment, non-empty line is a discrete repository definition in the
268+
traditional ``deb http://... suite components`` syntax.
269+
Returns a list of AptRepo instances.
270+
271+
Args:
272+
lines: Lines from a .list file
64273
"""
65-
Returns a list of installed apt sources:
274+
repos = []
275+
for raw in lines:
276+
line = raw.strip()
277+
if not line or line.startswith("#"):
278+
continue
279+
repo = parse_apt_repo(line)
280+
if repo:
281+
repos.append(repo)
282+
return repos
66283

67-
.. code:: python
68284

69-
[
70-
{
71-
"type": "deb",
72-
"url": "http://archive.ubuntu.org",
73-
"distribution": "trusty",
74-
"components", ["main", "multiverse"],
75-
},
76-
]
285+
class AptSources(FactBase):
286+
"""Returns a list of installed apt sources (legacy .list + deb822 .sources).
287+
288+
Returns a list of AptRepo instances that behave like dicts for backward compatibility:
289+
290+
[AptRepo(type="deb", url="http://archive.ubuntu.org", ...)]
291+
292+
Each AptRepo can be accessed like a dict:
293+
repo['type'] # works like repo.type
294+
repo.get('url') # works like getattr(repo, 'url')
77295
"""
78296

79297
@override
80298
def command(self) -> str:
81-
return make_cat_files_command(
82-
"/etc/apt/sources.list",
83-
"/etc/apt/sources.list.d/*.list",
299+
# We emit file boundary markers so the parser can select the correct
300+
# parsing function based on filename extension.
301+
return (
302+
"sh -c '"
303+
"for f in "
304+
"/etc/apt/sources.list "
305+
"/etc/apt/sources.list.d/*.list "
306+
"/etc/apt/sources.list.d/*.sources; do "
307+
'[ -e "$f" ] || continue; '
308+
'echo "##FILE $f"; '
309+
'cat "$f"; '
310+
"echo; "
311+
"done'"
84312
)
85313

86314
@override
87315
def requires_command(self) -> str:
88-
return "apt" # if apt installed, above should exist
316+
return "apt"
89317

90318
default = list
91319

92320
@override
93-
def process(self, output):
94-
repos = []
321+
def process(self, output): # type: ignore[override]
322+
repos: list[AptRepo] = []
323+
current_file: str | None = None
324+
buffer: list[str] = []
325+
326+
def flush():
327+
nonlocal buffer, current_file, repos
328+
if current_file is None or not buffer:
329+
buffer = []
330+
return
331+
332+
if current_file.endswith(".sources"):
333+
repos.extend(AptSourcesFile.parse_sources_file(buffer))
334+
else: # .list files or /etc/apt/sources.list
335+
repos.extend(parse_apt_list_file(buffer))
336+
buffer = []
95337

96338
for line in output:
97-
repo = parse_apt_repo(line)
98-
if repo:
99-
repos.append(repo)
339+
if line.startswith("##FILE "):
340+
flush() # flush previous file buffer
341+
current_file = line[7:].strip() # remove "##FILE " prefix
342+
continue
343+
buffer.append(line)
100344

345+
flush() # flush the final buffer
101346
return repos
102347

103348

tests/facts/apt.AptSources/component_with_number.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
{
22
"output": [
3-
"deb http://archive.ubuntu.com/ubuntu trusty restricted pi4"
3+
"##FILE /etc/apt/sources.list",
4+
"deb http://archive.ubuntu.com/ubuntu trusty restricted pi4",
5+
""
46
],
5-
"command": "(! test -f /etc/apt/sources.list || cat /etc/apt/sources.list) && (cat /etc/apt/sources.list.d/*.list || true)",
7+
"command": "sh -c 'for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [ -e \"$f\" ] || continue; echo \"##FILE $f\"; cat \"$f\"; echo; done'",
68
"requires_command": "apt",
79
"fact": [
810
{

0 commit comments

Comments
 (0)