debby 1.0.0__py3-none-any.whl

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.
debby/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """
2
+ .. include:: ../README.md
3
+ """
4
+
5
+ import importlib.metadata as metadata
6
+
7
+ __version__ = metadata.version(__package__ or __name__)
8
+
9
+ from .control_file import ControlFile
10
+ from .files import Files
11
+ from .meta import Meta
12
+ from .package import Package
13
+
14
+ __all__ = ("Package", "ControlFile", "Files", "Meta")
debby/__main__.py ADDED
@@ -0,0 +1,28 @@
1
+ import sys
2
+ from pathlib import Path
3
+ from typing import Sequence
4
+
5
+ from debby.args import Args
6
+ from debby.control_file import ControlFile
7
+ from debby.exceptions import DebbyError
8
+ from debby.files import Files
9
+ from debby.meta import MetaLoaderFactory
10
+ from debby.package import Package
11
+
12
+
13
+ def main(argv: Sequence[str] | None = None):
14
+ print(create_package(Args.parse(argv)))
15
+
16
+
17
+ if __name__ == "__main__":
18
+ try:
19
+ main()
20
+ except DebbyError as e:
21
+ print(e, file=sys.stderr)
22
+
23
+
24
+ def create_package(args: Args) -> Path:
25
+ meta = MetaLoaderFactory(args).loader().load()
26
+ files = Files(args.files)
27
+ package = Package(meta, ControlFile(meta, files, args.template), files)
28
+ return package.create(args.out_dir)
debby/args.py ADDED
@@ -0,0 +1,189 @@
1
+ import os
2
+ from argparse import ArgumentParser
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Literal, Self, Sequence
6
+
7
+
8
+ @dataclass
9
+ class Args:
10
+ files: Sequence[tuple[Path, Path]]
11
+ template: Path | None
12
+ out_dir: Path
13
+ pyproject: Path | None
14
+ poetry: Path | None
15
+ name: str | None
16
+ source: str | None
17
+ version: str | None
18
+ section: str | None
19
+ priority: str | None
20
+ architecture: str | None
21
+ essential: Literal["yes", "no"] | None
22
+ maintainer: str | None
23
+ description: str | None
24
+ homepage: str | None
25
+ depends: str | None
26
+ pre_depends: str | None
27
+ recommends: str | None
28
+ suggests: str | None
29
+ enhances: str | None
30
+ breaks: str | None
31
+ conflicts: str | None
32
+ no_size: bool = False
33
+
34
+ @classmethod
35
+ def parse(cls, argv: Sequence[str] | None = None) -> Self:
36
+ parser = ArgumentParser()
37
+ parser.add_argument(
38
+ "-f",
39
+ "--file",
40
+ dest="files",
41
+ help="Path to the files to package. Can be passed multiple times. E.g. --file SOURCE1 DESTINATION1 --file SOURCE2 DESTINATION2. Sources must point to existing files or directories and destinations will be treated as relative to the package root.",
42
+ type=Path,
43
+ nargs=2,
44
+ action="append",
45
+ default=[],
46
+ )
47
+ parser.add_argument(
48
+ "-t",
49
+ "--template",
50
+ help="Path to the control file template",
51
+ type=Path,
52
+ )
53
+ parser.add_argument(
54
+ "-o",
55
+ "--out-dir",
56
+ help="Path to the output directory",
57
+ type=Path,
58
+ default=Path("debian/build"),
59
+ )
60
+ parser.add_argument(
61
+ "--no-size",
62
+ help="Do not include the total size of the package in the control file",
63
+ action="store_true",
64
+ )
65
+ cls._add_meta_source_group(parser)
66
+ cls._add_meta_override_args(parser)
67
+ return cls(**vars(parser.parse_args(argv)))
68
+
69
+ @classmethod
70
+ def _add_meta_source_group(cls, parser: ArgumentParser) -> None:
71
+ meta_source_group = parser.add_mutually_exclusive_group(required=True)
72
+ meta_source_group.add_argument(
73
+ "--pyproject",
74
+ help="Read metadata according to PEP 621 from the given pyproject.toml file",
75
+ type=Path,
76
+ metavar="pyproject.toml",
77
+ )
78
+ meta_source_group.add_argument(
79
+ "--poetry",
80
+ help="Read poetry metadata from the given pyproject.toml file",
81
+ type=Path,
82
+ metavar="pyproject.toml",
83
+ )
84
+
85
+ @classmethod
86
+ def _add_meta_override_args(cls, parser: ArgumentParser) -> None:
87
+ meta_overrides_group = parser.add_argument_group(
88
+ "Metadata Overrides",
89
+ "Override metadata fields. Environment variables such as DEBBY_META_VERSION can be used to set these fields.",
90
+ )
91
+ meta_overrides_group.add_argument(
92
+ "-n",
93
+ "--name",
94
+ help="Specify the package name",
95
+ default=os.environ.get("DEBBY_META_NAME"),
96
+ )
97
+ meta_overrides_group.add_argument(
98
+ "-s",
99
+ "--source",
100
+ help="Specify the package source",
101
+ default=os.environ.get("DEBBY_META_SOURCE"),
102
+ )
103
+ meta_overrides_group.add_argument(
104
+ "-v",
105
+ "--version",
106
+ help="Specify the package version",
107
+ default=os.environ.get("DEBBY_META_VERSION"),
108
+ )
109
+ meta_overrides_group.add_argument(
110
+ "--section",
111
+ help="Specify the package section",
112
+ default=os.environ.get("DEBBY_META_SECTION"),
113
+ )
114
+ meta_overrides_group.add_argument(
115
+ "-p",
116
+ "--priority",
117
+ help="Specify the package priority",
118
+ default=os.environ.get("DEBBY_META_PRIORITY"),
119
+ )
120
+ meta_overrides_group.add_argument(
121
+ "-a",
122
+ "--architecture",
123
+ help="Specify the package architecture",
124
+ default=os.environ.get("DEBBY_META_ARCHITECTURE"),
125
+ )
126
+ meta_overrides_group.add_argument(
127
+ "-e",
128
+ "--essential",
129
+ choices=["yes", "no"],
130
+ help="Specify whether the package is essential",
131
+ default=os.environ.get("DEBBY_META_ESSENTIAL"),
132
+ )
133
+ meta_overrides_group.add_argument(
134
+ "-m",
135
+ "--maintainer",
136
+ help="Specify the package maintainer",
137
+ default=os.environ.get("DEBBY_META_MAINTAINER"),
138
+ )
139
+ meta_overrides_group.add_argument(
140
+ "-d",
141
+ "--description",
142
+ help="Specify the package description",
143
+ default=os.environ.get("DEBBY_META_DESCRIPTION"),
144
+ )
145
+ meta_overrides_group.add_argument(
146
+ "--homepage",
147
+ help="Specify the package homepage",
148
+ default=os.environ.get("DEBBY_META_HOMEPAGE"),
149
+ )
150
+ cls._add_dependencies_args(meta_overrides_group)
151
+
152
+ @classmethod
153
+ def _add_dependencies_args(cls, parser: ArgumentParser | Any) -> None:
154
+ parser.add_argument_group("Dependencies", "Specify package dependencies")
155
+ parser.add_argument(
156
+ "--depends",
157
+ help="Specify package dependencies",
158
+ default=os.environ.get("DEBBY_META_DEPENDS"),
159
+ )
160
+ parser.add_argument(
161
+ "--pre-depends",
162
+ help="Specify package pre-dependencies",
163
+ default=os.environ.get("DEBBY_META_PRE_DEPENDS"),
164
+ )
165
+ parser.add_argument(
166
+ "--recommends",
167
+ help="Specify package recommendations",
168
+ default=os.environ.get("DEBBY_META_RECOMMENDS"),
169
+ )
170
+ parser.add_argument(
171
+ "--suggests",
172
+ help="Specify package suggestions",
173
+ default=os.environ.get("DEBBY_META_SUGGESTS"),
174
+ )
175
+ parser.add_argument(
176
+ "--enhances",
177
+ help="Specify package enhancements",
178
+ default=os.environ.get("DEBBY_META_ENHANCES"),
179
+ )
180
+ parser.add_argument(
181
+ "--breaks",
182
+ help="Specify package breaks",
183
+ default=os.environ.get("DEBBY_META_BREAKS"),
184
+ )
185
+ parser.add_argument(
186
+ "--conflicts",
187
+ help="Specify package conflicts",
188
+ default=os.environ.get("DEBBY_META_CONFLICTS"),
189
+ )
debby/control_file.py ADDED
@@ -0,0 +1,63 @@
1
+ from pathlib import Path
2
+ from typing import Iterable
3
+
4
+ from debby.files import Files
5
+ from debby.meta import Meta
6
+
7
+
8
+ class ControlFile:
9
+ def __init__(
10
+ self,
11
+ meta: Meta,
12
+ files: Files,
13
+ template: Path | None = None,
14
+ include_size: bool = True,
15
+ ) -> None:
16
+ self._meta = meta
17
+ self._files = files
18
+ self._template = template
19
+ self._include_size = include_size
20
+
21
+ def create(self, path: Path) -> None:
22
+ path.write_text("\n".join(self._lines()) + "\n")
23
+
24
+ def _lines(self) -> Iterable[str]:
25
+ if not self._template:
26
+ return self._default_lines()
27
+ return (
28
+ line.format(metadata=self._meta, files=self._files)
29
+ for line in self._template.read_text().splitlines()
30
+ )
31
+
32
+ def _default_lines(self) -> Iterable[str]:
33
+ yield f"Package: {self._meta.name}"
34
+ if self._meta.source:
35
+ yield f"Source: {self._meta.source}"
36
+ yield f"Version: {self._meta.version}"
37
+ if self._meta.section:
38
+ yield f"Section: {self._meta.section}"
39
+ if self._meta.priority:
40
+ yield f"Priority: {self._meta.priority}"
41
+ yield f"Architecture: {self._meta.architecture}"
42
+ if self._meta.essential:
43
+ yield f"Essential: {self._meta.essential}"
44
+ if self._include_size and self._files.total_size:
45
+ yield f"Installed-Size: {self._files.total_size}"
46
+ yield f"Maintainer: {self._meta.maintainer}"
47
+ yield f"Description: {self._meta.description}"
48
+ if homepage := self._meta.homepage:
49
+ yield f"Homepage: {homepage}"
50
+ if depends := self._meta.depends:
51
+ yield f"Depends: {depends}"
52
+ if pre_depends := self._meta.pre_depends:
53
+ yield f"Pre-Depends: {pre_depends}"
54
+ if recommends := self._meta.recommends:
55
+ yield f"Recommends: {recommends}"
56
+ if suggests := self._meta.suggests:
57
+ yield f"Suggests: {suggests}"
58
+ if enhances := self._meta.enhances:
59
+ yield f"Enhances: {enhances}"
60
+ if breaks := self._meta.breaks:
61
+ yield f"Breaks: {breaks}"
62
+ if conflicts := self._meta.conflicts:
63
+ yield f"Conflicts: {conflicts}"
debby/exceptions.py ADDED
@@ -0,0 +1,9 @@
1
+ class DebbyError(Exception):
2
+ """Base class for all Debby exceptions."""
3
+
4
+
5
+ class MissingMetadataError(DebbyError):
6
+ """Raised when required metadata is missing."""
7
+
8
+ def __str__(self) -> str:
9
+ return f"Missing some required metadata. Make sure it's present in any of the sources, or provide it as a command-line argument or environment variable. {super().__str__()}"
debby/files.py ADDED
@@ -0,0 +1,60 @@
1
+ from functools import cached_property
2
+ from pathlib import Path
3
+ from typing import Iterable, Iterator, Mapping
4
+
5
+
6
+ class Files(Mapping[Path, Path]):
7
+ """A mapping of target paths in the package to source paths on the filesystem.
8
+
9
+ Args:
10
+ files: An iterable of pairs of source paths and destination paths.
11
+ """
12
+
13
+ def __init__(self, files: Iterable[tuple[Path, Path]]) -> None:
14
+ self._files = self._normalize_files(files)
15
+
16
+ def __post_init__(self) -> None:
17
+ if any(
18
+ not src.exists() or dst.is_absolute() for dst, src in self._files.items()
19
+ ):
20
+ raise ValueError(
21
+ "source files must all exist and destination paths must all be relative"
22
+ )
23
+
24
+ def package(self, path: Path) -> None:
25
+ for dst, src in self.items():
26
+ target = path / dst
27
+ target.parent.mkdir(parents=True, exist_ok=True)
28
+ target.symlink_to(src)
29
+
30
+ @classmethod
31
+ def _normalize_files(
32
+ cls, files: Iterable[tuple[Path, Path]]
33
+ ) -> Mapping[Path, Path]:
34
+ return {
35
+ dst.relative_to(dst.anchor) if dst.is_absolute() else dst: src
36
+ for src, dst in files
37
+ }
38
+
39
+ @property
40
+ def sources(self) -> Iterable[Path]:
41
+ return self._files.values()
42
+
43
+ @property
44
+ def destinations(self) -> Iterable[Path]:
45
+ return self._files.keys()
46
+
47
+ @cached_property
48
+ def total_size(self) -> int:
49
+ return sum(
50
+ (max(f.resolve().stat().st_size, 1024) // 1024) * 1024 for f in self.sources
51
+ )
52
+
53
+ def __getitem__(self, key: Path) -> Path:
54
+ return self._files[key]
55
+
56
+ def __iter__(self) -> Iterator[Path]:
57
+ return iter(self._files)
58
+
59
+ def __len__(self) -> int:
60
+ return len(self._files)
debby/meta/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .meta import Meta
2
+ from .meta_loader import MetaLoader
3
+ from .meta_loader_factory import MetaLoaderFactory
4
+
5
+ __all__ = ("Meta", "MetaLoader", "MetaLoaderFactory")
debby/meta/meta.py ADDED
@@ -0,0 +1,51 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal, TypedDict
3
+
4
+
5
+ class MetaVars(TypedDict, total=False):
6
+ """A dictionary of metadata variables."""
7
+
8
+ name: str
9
+ source: str
10
+ version: str
11
+ section: str
12
+ priority: str
13
+ architecture: str
14
+ essential: Literal["yes", "no"]
15
+ maintainer: str
16
+ description: str
17
+ homepage: str
18
+ depends: str
19
+ pre_depends: str
20
+ recommends: str
21
+ suggests: str
22
+ enhances: str
23
+ breaks: str
24
+ conflicts: str
25
+
26
+
27
+ @dataclass(kw_only=True, frozen=True, slots=True)
28
+ class Meta:
29
+ """Metadata for a Debian package."""
30
+
31
+ name: str
32
+ source: str | None = None
33
+ version: str
34
+ section: str | None = None
35
+ priority: str | None = None
36
+ architecture: str = "all"
37
+ essential: Literal["yes", "no"] | None = None
38
+ maintainer: str
39
+ description: str
40
+ homepage: str | None = None
41
+ depends: str | None = None
42
+ pre_depends: str | None = None
43
+ recommends: str | None = None
44
+ suggests: str | None = None
45
+ enhances: str | None = None
46
+ breaks: str | None = None
47
+ conflicts: str | None = None
48
+
49
+ @property
50
+ def full_name(self) -> str:
51
+ return f"{self.name}_{self.version}_{self.architecture}"
@@ -0,0 +1,68 @@
1
+ from debby.args import Args
2
+ from debby.exceptions import MissingMetadataError
3
+
4
+ from .meta import Meta, MetaVars
5
+
6
+
7
+ class MetaLoader:
8
+ """Load metadata from a source.
9
+
10
+ Classes that inherit from this class should implement the `load_from_source` method. The value they return will be overridden by the values specified by the user in the command-line arguments, and the resulting metadata will be returned.
11
+
12
+ Args:
13
+ args: The command-line arguments.
14
+ """
15
+
16
+ def __init__(self, args: Args) -> None:
17
+ self._args = args
18
+
19
+ def load(self) -> Meta:
20
+ """Load the metadata."""
21
+ kwargs: MetaVars = {**self.load_from_source(), **self.overrides()}
22
+ try:
23
+ return Meta(**kwargs)
24
+ except TypeError as e:
25
+ raise MissingMetadataError(str(e)) from None
26
+
27
+ def load_from_source(self) -> MetaVars:
28
+ """Load the metadata from the source."""
29
+ return {}
30
+
31
+ def overrides(self) -> MetaVars:
32
+ """Return metadata overrides as specified by the user."""
33
+ result: MetaVars = {}
34
+ if self._args.name:
35
+ result["name"] = self._args.name
36
+ if self._args.source:
37
+ result["source"] = self._args.source
38
+ if self._args.version:
39
+ result["version"] = self._args.version
40
+ if self._args.section:
41
+ result["section"] = self._args.section
42
+ if self._args.priority:
43
+ result["priority"] = self._args.priority
44
+ if self._args.architecture:
45
+ result["architecture"] = self._args.architecture
46
+ if self._args.essential:
47
+ result["essential"] = self._args.essential
48
+ if self._args.maintainer:
49
+ result["maintainer"] = self._args.maintainer
50
+ if self._args.description:
51
+ result["description"] = self._args.description
52
+ if self._args.homepage:
53
+ result["homepage"] = self._args.homepage
54
+ if self._args.depends:
55
+ result["depends"] = self._args.depends
56
+ if self._args.pre_depends:
57
+ result["pre_depends"] = self._args.pre_depends
58
+ if self._args.recommends:
59
+ result["recommends"] = self._args.recommends
60
+ if self._args.suggests:
61
+ result["suggests"] = self._args.suggests
62
+ if self._args.enhances:
63
+ result["enhances"] = self._args.enhances
64
+ if self._args.breaks:
65
+ result["breaks"] = self._args.breaks
66
+ if self._args.conflicts:
67
+ result["conflicts"] = self._args.conflicts
68
+ return result
@@ -0,0 +1,24 @@
1
+ from debby.args import Args
2
+
3
+ from .meta_loader import MetaLoader
4
+ from .poetry_meta_loader import PoetryMetaLoader
5
+ from .pyproject_meta_loader import PyprojectMetaLoader
6
+
7
+
8
+ class MetaLoaderFactory:
9
+ """Factory for creating an appropriate MetaLoader according to the given arguments.
10
+
11
+ Args:
12
+ args: The command-line arguments.
13
+ """
14
+
15
+ def __init__(self, args: Args) -> None:
16
+ self._args = args
17
+
18
+ def loader(self) -> MetaLoader:
19
+ """Create the appropriate MetaLoader."""
20
+ if self._args.pyproject:
21
+ return PyprojectMetaLoader(self._args)
22
+ if self._args.poetry:
23
+ return PoetryMetaLoader(self._args)
24
+ return MetaLoader(self._args)
@@ -0,0 +1,28 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+
4
+ from .meta import MetaVars
5
+ from .meta_loader import MetaLoader
6
+
7
+
8
+ class PoetryMetaLoader(MetaLoader):
9
+ """Load poetry metadata from a pyproject.toml file."""
10
+
11
+ @property
12
+ def _pyproject(self) -> Path:
13
+ assert self._args.poetry is not None
14
+ return self._args.poetry
15
+
16
+ def load_from_source(self) -> MetaVars:
17
+ poetry_data = tomllib.loads(self._pyproject.read_text())["tool"]["poetry"]
18
+ result: MetaVars = {
19
+ "name": poetry_data["name"],
20
+ "version": poetry_data["version"],
21
+ "description": poetry_data["description"],
22
+ "maintainer": next(
23
+ iter((*poetry_data.get("maintainers", ()), *poetry_data["authors"]))
24
+ ),
25
+ }
26
+ if homepage := poetry_data.get("homepage"):
27
+ result["homepage"] = homepage
28
+ return result
@@ -0,0 +1,57 @@
1
+ import tomllib
2
+ from pathlib import Path
3
+ from typing import Any, Iterable, Mapping, TypedDict
4
+
5
+ from .meta import MetaVars
6
+ from .meta_loader import MetaLoader
7
+
8
+
9
+ class PyprojectMetaLoader(MetaLoader):
10
+ """Load python project metadata according to PEP621 from a pyproject.toml file."""
11
+
12
+ @property
13
+ def _pyproject(self) -> Path:
14
+ assert self._args.pyproject is not None
15
+ return self._args.pyproject
16
+
17
+ def load_from_source(self) -> MetaVars:
18
+ project_data = tomllib.loads(self._pyproject.read_text())["project"]
19
+ result: MetaVars = {
20
+ "name": project_data["name"],
21
+ }
22
+ if "version" not in project_data["dynamic"]:
23
+ result["version"] = project_data["version"]
24
+ if maintainer := self._get_maintainer(project_data):
25
+ result["maintainer"] = maintainer
26
+ if description := project_data.get("description"):
27
+ result["description"] = description
28
+ if homepage := self._get_homepage(project_data.get("urls", {})):
29
+ result["homepage"] = homepage
30
+
31
+ return result
32
+
33
+ def _get_maintainer(self, project_data: Mapping[str, Any]) -> str | None:
34
+ people: Iterable[_Person] = (
35
+ project_data.get("maintainers") or project_data.get("authors") or ()
36
+ )
37
+ person = next(iter(people), None)
38
+ if person is None:
39
+ return None
40
+ if person.get("name") and person.get("email"):
41
+ return f"{person.get('name')} <{person.get('email')}>"
42
+ if person.get("name"):
43
+ return person.get("name")
44
+ if person.get("email"):
45
+ return person.get("email")
46
+ return None
47
+
48
+ def _get_homepage(self, urls: Mapping[str, str]) -> str | None:
49
+ for key in ("Homepage", "Repository", "Documentation"):
50
+ if url := urls.get(key):
51
+ return url
52
+ return next(iter(urls.values()), None)
53
+
54
+
55
+ class _Person(TypedDict, total=False):
56
+ name: str
57
+ email: str
debby/package.py ADDED
@@ -0,0 +1,30 @@
1
+ from pathlib import Path
2
+
3
+ from debby.control_file import ControlFile
4
+ from debby.files import Files
5
+ from debby.meta.meta import Meta
6
+
7
+
8
+ class Package:
9
+ """A directory containing a Debian package.
10
+
11
+ Args:
12
+ metadata: The metadata for the package.
13
+ control: The control file for the package.
14
+ files: The files to put in the package.
15
+ """
16
+
17
+ def __init__(self, metadata: Meta, control: ControlFile, files: Files) -> None:
18
+ self.metadata = metadata
19
+ self.control = control
20
+ self.files = files
21
+
22
+ def create(self, out_dir: Path) -> Path:
23
+ """Create the directory structure of the package, which can be packaged into a .deb file with dpkg-deb."""
24
+ directory = out_dir / self.metadata.full_name
25
+ control_dir = directory / "DEBIAN"
26
+ control_dir.mkdir(parents=True)
27
+ control_dir.chmod(0o755)
28
+ self.control.create(control_dir / "control")
29
+ self.files.package(directory)
30
+ return directory
debby/py.typed ADDED
@@ -0,0 +1 @@
1
+