debgraph 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: debgraph
3
+ Version: 0.2.0
4
+ Summary: Generates a graph of your debian packages
5
+ License-Expression: GPL-3.0-only
6
+ Project-URL: Homepage, https://github.com/garyg1/debgraph
7
+ Project-URL: Issues, https://github.com/garyg1/debgraph/issues
8
+ Requires-Python: >=3.7
9
+ Description-Content-Type: text/plain
10
+ License-File: LICENSE
11
+ License-File: COPYING
12
+ Requires-Dist: graphviz
13
+ Provides-Extra: tests
14
+ Requires-Dist: pytest; extra == "tests"
15
+ Requires-Dist: black; extra == "tests"
16
+ Dynamic: license-file
17
+
18
+ Debgraph, a program like debtree to view all the Debian packages on your system, in a single graph.
19
+
20
+ Supports GEXF, DOT (Graphviz), and JSON-Lines output.
21
+
22
+ In development. Please note that API may change across 0.x versions.
23
+
24
+ License
25
+ See COPYING
26
+
27
+ Installation
28
+ pip install debgraph
29
+
30
+ Usage
31
+ DOT Example
32
+ debgraph
33
+
34
+ head debian.dot
35
+
36
+ digraph Debian {
37
+ "adduser" [label="adduser"];
38
+ "adwaita-icon-theme" [label="adwaita-icon-theme"];
39
+ "alsa-topology-conf" [label="alsa-topology-conf"];
40
+ "alsa-ucm-conf" [label="alsa-ucm-conf"];
41
+ "apparmor" [label="apparmor"];
42
+ "apport" [label="apport"];
43
+ "apport-core-dump-handler" [label="apport-core-dump-handler"];
44
+ "apport-symptoms" [label="apport-symptoms"];
45
+ "appstream" [label="appstream"];
46
+ ...
47
+
48
+ GEXF Example
49
+ debgraph debian.gexf
50
+
51
+ head debian.gexf
52
+
53
+ <?xml version="1.0" encoding="UTF-8"?>
54
+ <gexf xmlns="http://www.gexf.net/1.3" version="1.3" xmlns:viz="http://www.gexf.net/1.3/viz" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.gexf.net/1.3 http://www.gexf.net/1.3/gexf.xsd">
55
+ <meta lastmodifieddate="2026-06-30">
56
+ <creator>debgraph</creator>
57
+ <description>A graph of apt packages on a Debian system.</description>
58
+ </meta>
59
+ <graph defaultedgetype="directed" idtype="string" type="static">
60
+
61
+ <attributes class="node">
62
+ <attribute id="0" title="binary:Synopsis" type="string"/>
63
+ ...
64
+
65
+ JSON-Lines Example
66
+ debgraph debian.jsonl
67
+
68
+ head debian.jsonl
69
+
70
+ {"id": 1, "name": "adduser", "version": "3.153ubuntu1", "dependencies": ... }
71
+ {"id": 2, "name": "adwaita-icon-theme", "version": "50.0-1", "dependencies": ... }
72
+ {"id": 3, "name": "alsa-topology-conf", "version": "1.2.5.1-3build1", "dependencies": ... }
73
+ {"id": 4, "name": "alsa-ucm-conf", "version": "1.2.15.3-1ubuntu1", "dependencies": ... }
74
+ ...
75
+
76
+ Development
77
+ pip install ".[tests]" .
78
+ python -m pytest .
debgraph-0.2.0/README ADDED
@@ -0,0 +1,61 @@
1
+ Debgraph, a program like debtree to view all the Debian packages on your system, in a single graph.
2
+
3
+ Supports GEXF, DOT (Graphviz), and JSON-Lines output.
4
+
5
+ In development. Please note that API may change across 0.x versions.
6
+
7
+ License
8
+ See COPYING
9
+
10
+ Installation
11
+ pip install debgraph
12
+
13
+ Usage
14
+ DOT Example
15
+ debgraph
16
+
17
+ head debian.dot
18
+
19
+ digraph Debian {
20
+ "adduser" [label="adduser"];
21
+ "adwaita-icon-theme" [label="adwaita-icon-theme"];
22
+ "alsa-topology-conf" [label="alsa-topology-conf"];
23
+ "alsa-ucm-conf" [label="alsa-ucm-conf"];
24
+ "apparmor" [label="apparmor"];
25
+ "apport" [label="apport"];
26
+ "apport-core-dump-handler" [label="apport-core-dump-handler"];
27
+ "apport-symptoms" [label="apport-symptoms"];
28
+ "appstream" [label="appstream"];
29
+ ...
30
+
31
+ GEXF Example
32
+ debgraph debian.gexf
33
+
34
+ head debian.gexf
35
+
36
+ <?xml version="1.0" encoding="UTF-8"?>
37
+ <gexf xmlns="http://www.gexf.net/1.3" version="1.3" xmlns:viz="http://www.gexf.net/1.3/viz" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.gexf.net/1.3 http://www.gexf.net/1.3/gexf.xsd">
38
+ <meta lastmodifieddate="2026-06-30">
39
+ <creator>debgraph</creator>
40
+ <description>A graph of apt packages on a Debian system.</description>
41
+ </meta>
42
+ <graph defaultedgetype="directed" idtype="string" type="static">
43
+
44
+ <attributes class="node">
45
+ <attribute id="0" title="binary:Synopsis" type="string"/>
46
+ ...
47
+
48
+ JSON-Lines Example
49
+ debgraph debian.jsonl
50
+
51
+ head debian.jsonl
52
+
53
+ {"id": 1, "name": "adduser", "version": "3.153ubuntu1", "dependencies": ... }
54
+ {"id": 2, "name": "adwaita-icon-theme", "version": "50.0-1", "dependencies": ... }
55
+ {"id": 3, "name": "alsa-topology-conf", "version": "1.2.5.1-3build1", "dependencies": ... }
56
+ {"id": 4, "name": "alsa-ucm-conf", "version": "1.2.15.3-1ubuntu1", "dependencies": ... }
57
+ ...
58
+
59
+ Development
60
+ pip install ".[tests]" .
61
+ python -m pytest .
@@ -0,0 +1,21 @@
1
+ from .debgraph import (
2
+ run_debgraph,
3
+ DpkgReader,
4
+ GraphFileWriter,
5
+ Package,
6
+ PackageDependencyAlternatives,
7
+ PackageDependency,
8
+ DebgraphError,
9
+ Options,
10
+ )
11
+
12
+ __all__ = [
13
+ run_debgraph,
14
+ DpkgReader,
15
+ GraphFileWriter,
16
+ Package,
17
+ PackageDependencyAlternatives,
18
+ PackageDependency,
19
+ DebgraphError,
20
+ Options,
21
+ ]
@@ -0,0 +1,515 @@
1
+ """
2
+ Debgraph, a program like debtree to view ALL the Debian packages on your system.
3
+ Copyright (C) 2026 Gary Gurlaskie
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, version 3 as
7
+ published by the Free Software Foundation.
8
+
9
+ This program is distributed in the hope that it will be useful,
10
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ GNU General Public License for more details.
13
+
14
+ You should have received a copy of the GNU General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from typing import List, Optional, Dict, Iterable, Any
22
+ from datetime import date
23
+ import argparse
24
+ import collections
25
+ import csv
26
+ import io
27
+ import json
28
+ import os
29
+ import re
30
+ import subprocess
31
+ import sys
32
+ from xml.sax.saxutils import escape, quoteattr
33
+ import graphviz
34
+
35
+ __version__ = "0.2.0"
36
+
37
+ _sep = "\n"
38
+
39
+
40
+ class GenericEncoder(json.JSONEncoder):
41
+ def default(self, obj):
42
+ if isinstance(obj, PackageDependencyAlternatives):
43
+ return {
44
+ **obj.__dict__,
45
+ "actuals": [
46
+ {"name": actual.name, "version": actual.version}
47
+ for actual in obj.actuals
48
+ ],
49
+ }
50
+ return obj.__dict__
51
+
52
+
53
+ class DebgraphError(Exception):
54
+ def __init__(self, message, posix_status):
55
+ self.posix_status = posix_status
56
+ self.message = message
57
+ super().__init__(message)
58
+
59
+
60
+ class PackageDependency:
61
+ """
62
+ Represents a dpkg package dependency, such as "apt-transport-https (= 3.2.0)".
63
+
64
+ See: https://www.debian.org/doc/debian-policy/ch-relationships.html.
65
+ """
66
+
67
+ def __init__(self, name, op=None, version=None):
68
+ self.name = name
69
+ self.op = op
70
+ self.version = version
71
+
72
+ def __repr__(self):
73
+ return json.dumps(self.__dict__, cls=GenericEncoder)
74
+
75
+
76
+ class PackageDependencyAlternatives:
77
+ """
78
+ Represents a dpkg package alternative dependency list, such as "apt-transport-https (= 3.2.0) | libappstream5 (= 1)".
79
+
80
+ See: https://www.debian.org/doc/debian-policy/ch-relationships.html.
81
+ """
82
+
83
+ def __init__(self, alts: List[PackageDependency]):
84
+ self.alts = alts
85
+ self.actuals: List[Package] = []
86
+
87
+ def register_actual(self, actual: Package):
88
+ # use list scan to simplify serialization
89
+ if actual not in self.actuals:
90
+ self.actuals.append(actual)
91
+
92
+ def __repr__(self):
93
+ return json.dumps(self.__dict__, cls=GenericEncoder)
94
+
95
+
96
+ def _identity_mapper(s: str) -> str:
97
+ return s
98
+
99
+
100
+ def _alt_syntax_mapper(s: str) -> str:
101
+ if s is None:
102
+ return None
103
+ serialized = Package._parse_package_refs(s)
104
+ return str(serialized)
105
+
106
+
107
+ class Package:
108
+ """
109
+ Represents a dpkg binary package. The supported fields are listed in get_all_dpkg_fields.
110
+ Implementation is not thread-safe.
111
+
112
+ See: https://www.debian.org/doc/debian-policy/ch-binary.html
113
+ """
114
+
115
+ _id = 1
116
+ _fields = [
117
+ "binary:Package",
118
+ "Depends",
119
+ ]
120
+ _extra_field_mapping = [
121
+ ("binary:Synopsis", "binary:Synopsis", _identity_mapper, "string"),
122
+ ("Breaks", "Breaks", _alt_syntax_mapper, "string"),
123
+ ("Description", "Description", _identity_mapper, "string"),
124
+ ("Enhances", "Enhances", _alt_syntax_mapper, "string"),
125
+ ("Installed-Size", "InstalledSizeKB", _identity_mapper, "integer"),
126
+ ("Essential", "IsEssential", _identity_mapper, "string"),
127
+ ("Maintainer", "Maintainer", _identity_mapper, "string"),
128
+ ("Origin", "Origin", _identity_mapper, "string"),
129
+ ("Provides", "Provides", _alt_syntax_mapper, "string"),
130
+ ("Recommends", "Recommends", _alt_syntax_mapper, "string"),
131
+ ("Replaces", "Replaces", _alt_syntax_mapper, "string"),
132
+ ("Section", "Section", _identity_mapper, "string"),
133
+ ("source:Package", "source:Package", _identity_mapper, "string"),
134
+ ("source:Version", "source:Version", _identity_mapper, "string"),
135
+ ("Suggests", "Suggests", _alt_syntax_mapper, "string"),
136
+ ("Version", "Version", _identity_mapper, "string"),
137
+ ]
138
+ _long_fields = [
139
+ "Description",
140
+ ]
141
+ _pkgref_re = r"(?P<name>[a-zA-Z0-9\+\-\._]+)\s*(\(\s*(?P<op>(\=|\>\=|\>\>|\<\=|\<\<))\s*(?P<version>[^\)]+)\s*\))?"
142
+
143
+ def __init__(self):
144
+ self.id = Package._id
145
+ Package._id += 1
146
+
147
+ self.name: Optional[str] = None
148
+ self.version: Optional[str] = None
149
+ self.dependencies: List[PackageDependencyAlternatives] = []
150
+ self.provides: List[PackageDependency] = []
151
+ self.extra: collections.defaultdict[str, Optional[str]] = (
152
+ collections.defaultdict(None)
153
+ )
154
+
155
+ @classmethod
156
+ def get_all_dpkg_fields(cls):
157
+ fields = [
158
+ *cls._fields,
159
+ *(dpkg_name for dpkg_name, _, _, _ in cls._extra_field_mapping),
160
+ ]
161
+ return fields
162
+
163
+ @classmethod
164
+ def get_all_extra_output_fields(cls):
165
+ return [
166
+ (output_name, output_type)
167
+ for _, output_name, _, output_type in cls._extra_field_mapping
168
+ ]
169
+
170
+ def __repr__(self):
171
+ return json.dumps(self.__dict__, cls=GenericEncoder)
172
+
173
+ def get_no_dep_repr(self):
174
+ return {"name": self.name, "version": self.version}
175
+
176
+ @classmethod
177
+ def _from_dict(cls, dict: Dict[str, str], long_fields: bool):
178
+ new = cls()
179
+ new.name = dict["binary:Package"]
180
+ new.version = dict["Version"]
181
+ new.dependencies = [
182
+ PackageDependencyAlternatives(altlist)
183
+ for altlist in cls._parse_package_refs(dict["Depends"])
184
+ ]
185
+ new.provides = cls._flatten(cls._parse_package_refs(dict["Provides"]))
186
+
187
+ for dpkg_field, output_field, map_fn, _ in cls._extra_field_mapping:
188
+ if not long_fields and dpkg_field in cls._long_fields:
189
+ continue
190
+
191
+ new.extra[output_field] = map_fn(dict.get(dpkg_field))
192
+
193
+ new.extra["Version"] = new.version
194
+
195
+ return new
196
+
197
+ @classmethod
198
+ def _parse_package_ref(cls, raw_ref):
199
+ """Parses a pkgref, i.e., `<name> (<op> <version>)"""
200
+ m = re.match(cls._pkgref_re, raw_ref)
201
+ if m:
202
+ return PackageDependency(m.group("name"), m.group("op"), m.group("version"))
203
+ else:
204
+ return None
205
+
206
+ @classmethod
207
+ def _parse_package_ref_alt(cls, raw_ref_alt):
208
+ """Parses a pkgrefalt, i.e., `<pkgref> | <pkgref> | ...`"""
209
+ ref_alt = raw_ref_alt.split("|")
210
+ alts = []
211
+ for raw in ref_alt:
212
+ raw = raw.strip()
213
+ if raw:
214
+ alts.append(cls._parse_package_ref(raw))
215
+ return alts
216
+
217
+ @classmethod
218
+ def _parse_package_refs(cls, raw_str):
219
+ """Parses a list of pkgrefalt, i.e., `<pkgrefalt>, <pkgrefalt>, ...`"""
220
+ raw_refs = raw_str.split(",")
221
+ refs = []
222
+ for raw_ref in raw_refs:
223
+ raw_ref = raw_ref.strip()
224
+ if raw_ref:
225
+ ref = cls._parse_package_ref_alt(raw_ref)
226
+ refs.append(ref)
227
+ return refs
228
+
229
+ @staticmethod
230
+ def _flatten(l):
231
+ return [x for sublist in l for x in sublist]
232
+
233
+
234
+ class DpkgReader:
235
+ start_entry_delimiter = "[[debgraph magic start entry]]\n"
236
+ comma_delimiter = "[[debgraph magic comma]]\n"
237
+
238
+ @classmethod
239
+ def _get_dpkg_stdout(cls):
240
+ # dpkg-query doesn't have a way of escaping CSV, so we use magic strings
241
+ # in order to produce machine-readable output.
242
+
243
+ cmd = [
244
+ "dpkg-query",
245
+ "--show",
246
+ "--showformat",
247
+ cls.start_entry_delimiter
248
+ + cls.comma_delimiter.join(
249
+ ("${" + field + "}" for field in Package.get_all_dpkg_fields())
250
+ ),
251
+ ]
252
+ result = subprocess.run(
253
+ cmd,
254
+ stdout=subprocess.PIPE,
255
+ stderr=subprocess.PIPE,
256
+ text=True,
257
+ )
258
+
259
+ if result.returncode != 0:
260
+ print(f"Failed: {result.returncode} {result.stderr}", file=sys.stderr)
261
+
262
+ return result.stdout
263
+
264
+ @classmethod
265
+ def _parse_dpkg_stdout(cls, s: str, long_fields: bool) -> List[Package]:
266
+ i = 0
267
+ packages = {}
268
+ while i < len(s):
269
+ next_idx = s.find(cls.start_entry_delimiter, i)
270
+ if next_idx == -1:
271
+ next_idx = len(s)
272
+ package_entry = s[i:next_idx]
273
+ values = package_entry.split(cls.comma_delimiter)
274
+ if len(values) == len(Package.get_all_dpkg_fields()):
275
+ dict_ = {
276
+ field: val
277
+ for field, val in zip(Package.get_all_dpkg_fields(), values)
278
+ }
279
+ package = Package._from_dict(dict_, long_fields=long_fields)
280
+ if package.name in packages:
281
+ raise DebgraphError(
282
+ f"Found duplicate packages: {package}, {packages[package.name]}",
283
+ 1,
284
+ )
285
+ packages[package.name] = package
286
+
287
+ i = next_idx + len(cls.start_entry_delimiter)
288
+
289
+ cls._postprocess_packages(packages)
290
+ return list(packages.values())
291
+
292
+ @classmethod
293
+ def _postprocess_packages(cls, packages: Dict[str, Package]):
294
+ providers = collections.defaultdict(list)
295
+ for package in packages.values():
296
+ for provided in package.provides:
297
+ providers[provided.name].append(package)
298
+
299
+ # for each dependency find the one(s) that actually provide it
300
+ for package in packages.values():
301
+ for alt in package.dependencies:
302
+ for requested in alt.alts:
303
+ if requested.name in packages:
304
+ alt.register_actual(packages[requested.name])
305
+ if requested.name in providers:
306
+ alt.register_actual(providers[requested.name][0])
307
+
308
+
309
+ class Options:
310
+ def __init__(
311
+ self,
312
+ use_fixed_dates: bool = False,
313
+ override_input_stream: Optional[str] = None,
314
+ argv: Optional[List[str]] = None,
315
+ ):
316
+ self.use_fixed_dates = use_fixed_dates
317
+ self.override_input_stream = override_input_stream
318
+ self.argv = argv
319
+
320
+
321
+ class GraphFileWriter:
322
+ @staticmethod
323
+ def _write_jsonl(
324
+ fout: io.TextIOWrapper, packages: Iterable[Package], options: Options
325
+ ):
326
+ fout.write("\n".join(map(str, packages)))
327
+
328
+ @staticmethod
329
+ def _write_dotfile(
330
+ fout: io.TextIOWrapper, packages: Iterable[Package], options: Options
331
+ ):
332
+ dot = graphviz.Digraph("Debian")
333
+
334
+ for package in packages:
335
+ dot.node(package.name, label=package.name, _attributes=package.extra)
336
+
337
+ for package in packages:
338
+ for alt in package.dependencies:
339
+ for actual in alt.actuals:
340
+ dot.edge(package.name, actual.name, label=str(alt.alts))
341
+
342
+ fout.write(dot.source)
343
+
344
+ @staticmethod
345
+ def _write_gexf(
346
+ fout: io.TextIOWrapper, packages: Iterable[Package], options: Options
347
+ ):
348
+ today_iso = (
349
+ "2026-06-30"
350
+ if options.use_fixed_dates
351
+ else date.today().strftime("%Y-%m-%d")
352
+ )
353
+ creator = "debgraph"
354
+ description = "A graph of apt packages on a Debian system."
355
+ nodes = []
356
+ edges = []
357
+ node_attributes = []
358
+ edge_attributes = []
359
+
360
+ field_to_index = {
361
+ field: idx
362
+ for idx, (field, _) in enumerate(Package.get_all_extra_output_fields())
363
+ }
364
+ for idx, (field, type_) in enumerate(Package.get_all_extra_output_fields()):
365
+ node_attributes.append(
366
+ f""" <attribute id="{idx}" title="{field}" type="{type_}"/>"""
367
+ )
368
+
369
+ edge_attributes.append(
370
+ f""" <attribute id="0" title="alts" type="string"/>"""
371
+ )
372
+
373
+ for package in packages:
374
+ attvalues = []
375
+ for field, value in package.extra.items():
376
+ attvalues.append(
377
+ f""" <attvalue for="{field_to_index[field]}" value={quoteattr(value)} />"""
378
+ )
379
+ nodes.append(
380
+ f""" <node id="{package.id}" label={quoteattr(package.name)}>
381
+ <attvalues>
382
+ {_sep.join(attvalues)}
383
+ </attvalues>
384
+ </node>"""
385
+ )
386
+
387
+ for package in packages:
388
+ for alt in package.dependencies:
389
+ for actual in alt.actuals:
390
+ attvalues = []
391
+ attvalues.append(
392
+ f""" <attvalue for="0" value={quoteattr(str(alt.alts))} />"""
393
+ )
394
+ edges.append(
395
+ f""" <edge source="{package.id}" target="{actual.id}">
396
+ <attvalues>
397
+ {_sep.join(attvalues)}
398
+ </attvalues>
399
+ </edge>"""
400
+ )
401
+
402
+ xml = f"""<?xml version="1.0" encoding="UTF-8"?>
403
+ <gexf xmlns="http://www.gexf.net/1.3" version="1.3" xmlns:viz="http://www.gexf.net/1.3/viz" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.gexf.net/1.3 http://www.gexf.net/1.3/gexf.xsd">
404
+ <meta lastmodifieddate="{today_iso}">
405
+ <creator>{escape(creator)}</creator>
406
+ <description>{escape(description)}</description>
407
+ </meta>
408
+
409
+ <graph defaultedgetype="directed" idtype="string" type="static">
410
+ <attributes class="node">
411
+ {_sep.join(node_attributes)}
412
+ </attributes>
413
+
414
+ <attributes class="edge">
415
+ {_sep.join(edge_attributes)}
416
+ </attributes>
417
+
418
+ <nodes count="{len(nodes)}">
419
+ {_sep.join(nodes)}
420
+ </nodes>
421
+
422
+ <edges>
423
+ {_sep.join(edges)}
424
+ </edges>
425
+ </graph>
426
+ </gexf>"""
427
+
428
+ fout.write(xml)
429
+
430
+
431
+ _supported_formats = {
432
+ "dot": GraphFileWriter._write_dotfile,
433
+ "gexf": GraphFileWriter._write_gexf,
434
+ "jsonl": GraphFileWriter._write_jsonl,
435
+ }
436
+
437
+
438
+ def _infer_format(filename: str):
439
+ _, ext = os.path.splitext(filename)
440
+ format_str = ext[len(os.path.extsep) :].lower()
441
+ if format_str in _supported_formats:
442
+ return format_str
443
+ else:
444
+ raise DebgraphError(
445
+ f"Could not infer format from {filename} with extension {ext.lower()}, please specify it explictly using -t.",
446
+ 1,
447
+ )
448
+
449
+
450
+ def run_debgraph(options: Options):
451
+ ap = argparse.ArgumentParser("debgraph")
452
+ ap.add_argument(
453
+ "output", nargs="?", default="debian.dot", help="Name of the output file"
454
+ )
455
+ ap.add_argument(
456
+ "-t",
457
+ "--format",
458
+ required=False,
459
+ choices=_supported_formats.keys(),
460
+ help="Output format, can be inferred from --output.",
461
+ )
462
+ ap.add_argument(
463
+ "-v",
464
+ "--version",
465
+ help="Print version and exit.",
466
+ action="store_true",
467
+ )
468
+ ap.add_argument(
469
+ "-l",
470
+ "--long",
471
+ help="Include long fields (Description) in the graph",
472
+ action="store_true",
473
+ )
474
+ args = ap.parse_args(options.argv)
475
+
476
+ if args.version:
477
+ raise DebgraphError(f"Debgraph {__version__}", 0)
478
+
479
+ # reset numbering for this graph
480
+ Package._id = 1
481
+
482
+ output_abs_path = os.path.abspath(args.output)
483
+ dirname, filename = os.path.split(output_abs_path)
484
+
485
+ format = args.format
486
+ if format is None:
487
+ format = _infer_format(filename)
488
+ print(f"Using format {format}", file=sys.stderr)
489
+ write_fn = _supported_formats[format]
490
+
491
+ s = (
492
+ DpkgReader._get_dpkg_stdout()
493
+ if not options.override_input_stream
494
+ else options.override_input_stream
495
+ )
496
+ packages = DpkgReader._parse_dpkg_stdout(s, long_fields=args.long)
497
+
498
+ os.makedirs(dirname, exist_ok=True)
499
+ with open(output_abs_path, "w") as fout:
500
+ write_fn(fout, packages, options)
501
+
502
+ print(f"Finished writing output to {output_abs_path}")
503
+
504
+
505
+ def main():
506
+ try:
507
+ options = Options()
508
+ run_debgraph(options)
509
+ except DebgraphError as e:
510
+ print(e.message, file=sys.stdout if e.posix_status == 0 else sys.stderr)
511
+ sys.exit(e.posix_status)
512
+
513
+
514
+ if __name__ == "__main__":
515
+ main()
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: debgraph
3
+ Version: 0.2.0
4
+ Summary: Generates a graph of your debian packages
5
+ License-Expression: GPL-3.0-only
6
+ Project-URL: Homepage, https://github.com/garyg1/debgraph
7
+ Project-URL: Issues, https://github.com/garyg1/debgraph/issues
8
+ Requires-Python: >=3.7
9
+ Description-Content-Type: text/plain
10
+ License-File: LICENSE
11
+ License-File: COPYING
12
+ Requires-Dist: graphviz
13
+ Provides-Extra: tests
14
+ Requires-Dist: pytest; extra == "tests"
15
+ Requires-Dist: black; extra == "tests"
16
+ Dynamic: license-file
17
+
18
+ Debgraph, a program like debtree to view all the Debian packages on your system, in a single graph.
19
+
20
+ Supports GEXF, DOT (Graphviz), and JSON-Lines output.
21
+
22
+ In development. Please note that API may change across 0.x versions.
23
+
24
+ License
25
+ See COPYING
26
+
27
+ Installation
28
+ pip install debgraph
29
+
30
+ Usage
31
+ DOT Example
32
+ debgraph
33
+
34
+ head debian.dot
35
+
36
+ digraph Debian {
37
+ "adduser" [label="adduser"];
38
+ "adwaita-icon-theme" [label="adwaita-icon-theme"];
39
+ "alsa-topology-conf" [label="alsa-topology-conf"];
40
+ "alsa-ucm-conf" [label="alsa-ucm-conf"];
41
+ "apparmor" [label="apparmor"];
42
+ "apport" [label="apport"];
43
+ "apport-core-dump-handler" [label="apport-core-dump-handler"];
44
+ "apport-symptoms" [label="apport-symptoms"];
45
+ "appstream" [label="appstream"];
46
+ ...
47
+
48
+ GEXF Example
49
+ debgraph debian.gexf
50
+
51
+ head debian.gexf
52
+
53
+ <?xml version="1.0" encoding="UTF-8"?>
54
+ <gexf xmlns="http://www.gexf.net/1.3" version="1.3" xmlns:viz="http://www.gexf.net/1.3/viz" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.gexf.net/1.3 http://www.gexf.net/1.3/gexf.xsd">
55
+ <meta lastmodifieddate="2026-06-30">
56
+ <creator>debgraph</creator>
57
+ <description>A graph of apt packages on a Debian system.</description>
58
+ </meta>
59
+ <graph defaultedgetype="directed" idtype="string" type="static">
60
+
61
+ <attributes class="node">
62
+ <attribute id="0" title="binary:Synopsis" type="string"/>
63
+ ...
64
+
65
+ JSON-Lines Example
66
+ debgraph debian.jsonl
67
+
68
+ head debian.jsonl
69
+
70
+ {"id": 1, "name": "adduser", "version": "3.153ubuntu1", "dependencies": ... }
71
+ {"id": 2, "name": "adwaita-icon-theme", "version": "50.0-1", "dependencies": ... }
72
+ {"id": 3, "name": "alsa-topology-conf", "version": "1.2.5.1-3build1", "dependencies": ... }
73
+ {"id": 4, "name": "alsa-ucm-conf", "version": "1.2.15.3-1ubuntu1", "dependencies": ... }
74
+ ...
75
+
76
+ Development
77
+ pip install ".[tests]" .
78
+ python -m pytest .
@@ -2,9 +2,12 @@ COPYING
2
2
  LICENSE
3
3
  README
4
4
  pyproject.toml
5
+ debgraph/__init__.py
5
6
  debgraph/debgraph.py
6
7
  debgraph.egg-info/PKG-INFO
7
8
  debgraph.egg-info/SOURCES.txt
8
9
  debgraph.egg-info/dependency_links.txt
9
10
  debgraph.egg-info/entry_points.txt
10
- debgraph.egg-info/top_level.txt
11
+ debgraph.egg-info/requires.txt
12
+ debgraph.egg-info/top_level.txt
13
+ tests/test_debgraph.py
@@ -0,0 +1,5 @@
1
+ graphviz
2
+
3
+ [tests]
4
+ pytest
5
+ black
@@ -1,16 +1,19 @@
1
1
  [project]
2
2
  name = "debgraph"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Generates a graph of your debian packages"
5
5
  requires-python = ">=3.7"
6
- dependencies = []
6
+ dependencies = ["graphviz"]
7
7
  license = "GPL-3.0-only"
8
8
  license-files = [ "LICENSE", "COPYING" ]
9
- readme = "README"
9
+ readme = { file = "README", content-type = "text/plain" }
10
10
 
11
11
  [project.urls]
12
12
  Homepage = "https://github.com/garyg1/debgraph"
13
13
  Issues = "https://github.com/garyg1/debgraph/issues"
14
14
 
15
15
  [project.scripts]
16
- debgraph = "debgraph.debgraph:main"
16
+ debgraph = "debgraph.debgraph:main"
17
+
18
+ [project.optional-dependencies]
19
+ tests = ["pytest", "black"]
@@ -0,0 +1,64 @@
1
+ import difflib
2
+ import pytest
3
+ import debgraph
4
+ import pathlib
5
+ import os
6
+ from . import make_fixture
7
+ import subprocess
8
+ import tempfile
9
+
10
+
11
+ def test_parser():
12
+ tests = [
13
+ [
14
+ "apt-transport-https (= 3.2.0)",
15
+ {"name": "apt-transport-https", "op": "=", "version": "3.2.0"},
16
+ ],
17
+ ["libappstream5 (= 1)", {"name": "libappstream5", "op": "=", "version": "1"}],
18
+ ]
19
+ for s, expected in tests:
20
+ assert debgraph.Package._parse_package_ref(s).__dict__ == expected
21
+
22
+
23
+ def _assert_files_equal(file1, file2):
24
+ diff = list(difflib.unified_diff(file1.readlines(), file2.readlines()))
25
+ assert diff == [], "".join(diff)
26
+
27
+
28
+ def _test_fixture(fixture: str, *extra_args: str):
29
+ with tempfile.TemporaryDirectory() as temp_dir:
30
+ in_fixture, out_fixtures = make_fixture.get_fixtures(fixture)
31
+
32
+ with open(in_fixture, "r") as in_file:
33
+ s = in_file.read()
34
+
35
+ for expected_fixture, _ in out_fixtures:
36
+ actual_fixture = os.path.join(temp_dir, os.path.split(expected_fixture)[1])
37
+
38
+ debgraph.run_debgraph(
39
+ debgraph.Options(
40
+ use_fixed_dates=True,
41
+ override_input_stream=s,
42
+ argv=[actual_fixture, *extra_args],
43
+ )
44
+ )
45
+
46
+ with open(actual_fixture, "r") as actual, open(
47
+ expected_fixture, "r"
48
+ ) as expected:
49
+ _assert_files_equal(actual, expected)
50
+
51
+
52
+ def test_wsl():
53
+ _test_fixture("wsl")
54
+ _test_fixture("wsl_long", "--long")
55
+
56
+
57
+ def test_version():
58
+ e = None
59
+ try:
60
+ debgraph.run_debgraph(debgraph.Options(argv=["--version"]))
61
+ except debgraph.DebgraphError as actual:
62
+ e = actual
63
+
64
+ assert e != None and e.message == "Debgraph 0.2.0"
debgraph-0.1.0/PKG-INFO DELETED
@@ -1,39 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: debgraph
3
- Version: 0.1.0
4
- Summary: Generates a graph of your debian packages
5
- License-Expression: GPL-3.0-only
6
- Project-URL: Homepage, https://github.com/garyg1/debgraph
7
- Project-URL: Issues, https://github.com/garyg1/debgraph/issues
8
- Requires-Python: >=3.7
9
- License-File: LICENSE
10
- License-File: COPYING
11
- Dynamic: license-file
12
-
13
- Debgraph, a program like debtree to view ALL the Debian packages on your system.
14
-
15
- License
16
- See COPYING
17
-
18
- Installation
19
- pip install debgraph
20
-
21
- Usage
22
- debgraph
23
-
24
- Output Format
25
- head debian.dot
26
-
27
- ```
28
- digraph Debian {
29
- "adduser" [label="adduser"];
30
- "adwaita-icon-theme" [label="adwaita-icon-theme"];
31
- "alsa-topology-conf" [label="alsa-topology-conf"];
32
- "alsa-ucm-conf" [label="alsa-ucm-conf"];
33
- "apparmor" [label="apparmor"];
34
- "apport" [label="apport"];
35
- "apport-core-dump-handler" [label="apport-core-dump-handler"];
36
- "apport-symptoms" [label="apport-symptoms"];
37
- "appstream" [label="appstream"];
38
- ...
39
- ```
debgraph-0.1.0/README DELETED
@@ -1,27 +0,0 @@
1
- Debgraph, a program like debtree to view ALL the Debian packages on your system.
2
-
3
- License
4
- See COPYING
5
-
6
- Installation
7
- pip install debgraph
8
-
9
- Usage
10
- debgraph
11
-
12
- Output Format
13
- head debian.dot
14
-
15
- ```
16
- digraph Debian {
17
- "adduser" [label="adduser"];
18
- "adwaita-icon-theme" [label="adwaita-icon-theme"];
19
- "alsa-topology-conf" [label="alsa-topology-conf"];
20
- "alsa-ucm-conf" [label="alsa-ucm-conf"];
21
- "apparmor" [label="apparmor"];
22
- "apport" [label="apport"];
23
- "apport-core-dump-handler" [label="apport-core-dump-handler"];
24
- "apport-symptoms" [label="apport-symptoms"];
25
- "appstream" [label="appstream"];
26
- ...
27
- ```
@@ -1,188 +0,0 @@
1
- '''
2
- Debgraph, a program like debtree to view ALL the Debian packages on your system.
3
- Copyright (C) 2026 Gary Gurlaskie
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, version 3 as
7
- published by the Free Software Foundation.
8
-
9
- This program is distributed in the hope that it will be useful,
10
- but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- GNU General Public License for more details.
13
-
14
- You should have received a copy of the GNU General Public License
15
- along with this program. If not, see <https://www.gnu.org/licenses/>.
16
-
17
- '''
18
-
19
- from __future__ import annotations
20
-
21
- import collections
22
- import subprocess
23
- import sys
24
- import csv
25
- import io
26
- import re
27
- import json
28
- from typing import List, Optional, Dict
29
-
30
- class GenericEncoder(json.JSONEncoder):
31
- def default(self, obj):
32
- return obj.__dict__
33
-
34
- class PackageRef:
35
- def __init__(self, name, op=None, version=None):
36
- self.name = name
37
- self.op = op
38
- self.version = version
39
-
40
- def __repr__(self):
41
- return json.dumps(self.__dict__, cls=GenericEncoder)
42
-
43
-
44
- class PackageDependencyAlt:
45
- def __init__(self, alts: List[PackageRef]):
46
- self.alts = alts
47
- self.actual: Optional[Package] = None
48
-
49
-
50
- class Package:
51
- fields = [
52
- 'binary:Package',
53
- 'Version',
54
- 'Depends',
55
- 'Provides',
56
- 'Maintainer',
57
- ]
58
- _pkgref_re = r"(?P<name>[a-zA-Z0-9\+\-\._]+)\s*(\(\s*(?P<op>(\=|\>\=|\>\>|\<\=|\<\<))\s*(?P<version>[^\)]+)\s*\))?"
59
-
60
- def __init__(self):
61
- self.name: Optional[str] = None
62
- self.version: Optional[str] = None
63
- self.dependencies: List[PackageDependencyAlt] = []
64
- self.provides: List[PackageRef] = []
65
- self.maintainer: Optional[str] = None
66
-
67
- @classmethod
68
- def from_dict(cls, dict):
69
- new = Package()
70
- new.name = dict['binary:Package']
71
- new.version= dict['Version']
72
- new.dependencies = list(map(PackageDependencyAlt, cls.parse_package_refs(dict['Depends'])))
73
- new.provides = cls.flatten(cls.parse_package_refs(dict['Provides']))
74
- new.maintainer = dict['Maintainer']
75
- return new
76
-
77
-
78
- @classmethod
79
- def parse_package_ref(cls, raw_ref):
80
- """Parses `<name> (<op> <version>)"""
81
- m = re.match(cls._pkgref_re, raw_ref)
82
- if m:
83
- return PackageRef(m.group('name'), m.group('op'), m.group('version'))
84
- else:
85
- return None
86
-
87
- @classmethod
88
- def parse_package_ref_alt(cls, raw_ref_alt):
89
- """Parses <pkgref> | <pkgref> | ..."""
90
- ref_alt = raw_ref_alt.split('|')
91
- alts = []
92
- for raw in ref_alt:
93
- raw = raw.strip()
94
- if raw:
95
- alts.append(cls.parse_package_ref(raw))
96
- return alts
97
-
98
- @classmethod
99
- def parse_package_refs(cls, raw_str):
100
- """Parses `<pkgrefmany>, <pkgrefmany>, ...`"""
101
- raw_refs = raw_str.split(',')
102
- refs = []
103
- for raw_ref in raw_refs:
104
- raw_ref = raw_ref.strip()
105
- if raw_ref:
106
- ref = cls.parse_package_ref_alt(raw_ref)
107
- refs.append(ref)
108
- return refs
109
-
110
- @staticmethod
111
- def flatten(l):
112
- return [x for sublist in l for x in sublist]
113
-
114
- def __repr__(self):
115
- return json.dumps(self.__dict__, cls=GenericEncoder)
116
-
117
-
118
-
119
- def main():
120
- result = subprocess.run(
121
- ['dpkg-query', '--show', '--showformat', ','.join(('"${' + field + '}"' for field in Package.fields )) + '\n'],
122
- stdout=subprocess.PIPE,
123
- stderr=subprocess.PIPE,
124
- text=True
125
- )
126
-
127
- if result.returncode != 0:
128
- print(f"Failed: {result.returncode} {result.stderr}")
129
- sys.exit(1)
130
-
131
- reader = csv.DictReader(io.StringIO(result.stdout), fieldnames=Package.fields)
132
-
133
- packages: Dict[str, Package] = {}
134
- for dict_ in reader:
135
- package = Package.from_dict(dict_)
136
- assert package.name not in packages
137
- packages[package.name] = package
138
-
139
- providers = collections.defaultdict(list)
140
- for package in packages.values():
141
- for provided in package.provides:
142
- providers[provided.name].append(package)
143
-
144
-
145
- # for each dependency find which actually provides it
146
- for package in packages.values():
147
- for alt in package.dependencies:
148
- for requested in alt.alts:
149
- if requested.name in packages:
150
- alt.actual = packages[requested.name]
151
- break
152
- if requested.name in providers:
153
- alt.actual = providers[requested.name][0]
154
- break
155
-
156
- # render the dotfile
157
- # graphviz library tries to position them which is not useful
158
- # so write to stdout
159
- output = [
160
- "digraph Debian {"
161
- ]
162
-
163
- for package in packages.values():
164
- output.append(f'"{package.name}" [label="{package.name}"];')
165
-
166
- for package in packages.values():
167
- for alt in package.dependencies:
168
- if alt.actual is not None:
169
- output.append(f'"{package.name}" -> "{alt.actual.name}";')
170
-
171
- output.append("}")
172
-
173
- with open('debian.dot', 'w') as fout:
174
- fout.write('\n'.join(output))
175
-
176
-
177
-
178
- def test():
179
- tests = [
180
- 'apt-transport-https (= 3.2.0)',
181
- 'libappstream5 (= 1)',
182
- ]
183
- for test in tests:
184
- print(parse_package_ref(test))
185
-
186
-
187
- if __name__ == '__main__':
188
- main()
@@ -1,39 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: debgraph
3
- Version: 0.1.0
4
- Summary: Generates a graph of your debian packages
5
- License-Expression: GPL-3.0-only
6
- Project-URL: Homepage, https://github.com/garyg1/debgraph
7
- Project-URL: Issues, https://github.com/garyg1/debgraph/issues
8
- Requires-Python: >=3.7
9
- License-File: LICENSE
10
- License-File: COPYING
11
- Dynamic: license-file
12
-
13
- Debgraph, a program like debtree to view ALL the Debian packages on your system.
14
-
15
- License
16
- See COPYING
17
-
18
- Installation
19
- pip install debgraph
20
-
21
- Usage
22
- debgraph
23
-
24
- Output Format
25
- head debian.dot
26
-
27
- ```
28
- digraph Debian {
29
- "adduser" [label="adduser"];
30
- "adwaita-icon-theme" [label="adwaita-icon-theme"];
31
- "alsa-topology-conf" [label="alsa-topology-conf"];
32
- "alsa-ucm-conf" [label="alsa-ucm-conf"];
33
- "apparmor" [label="apparmor"];
34
- "apport" [label="apport"];
35
- "apport-core-dump-handler" [label="apport-core-dump-handler"];
36
- "apport-symptoms" [label="apport-symptoms"];
37
- "appstream" [label="appstream"];
38
- ...
39
- ```
File without changes
File without changes
File without changes