breadpunk
/
breadpack
Archived
2
0
Fork 0

Last Hurrah

breadpunk.club is closing down and I never really got around to making
breadpack.  I found this attempt at starting *something* with it while
cleaning up my home directory, so here it is committed to posterity.

Breadpunk started as a simple joke on IRC about life being pain, and
pain being the french word for bread.  It somehow morphed into starting
a bread tilde and making me learn to set one up, to run an IRC server,
to set up local emails, etc.  While I definitely lacked energy, time,
or motivation, or sometimes all three at once, and did not get to do
most of the things I said I would do, I am still glad this thing existed
for a little while.

So long, bakers.  Thank you for baking with us.
May your lives not be pain, but pretty good toast.
This commit is contained in:
lucidiot 2023-02-23 21:11:56 +01:00
parent 607905241e
commit 8f56f362ef
2 changed files with 149 additions and 17 deletions

View File

@ -2,6 +2,8 @@
breadpunk.club's in-house package management system (prototype).
[TOC]
## global options
```
@ -25,6 +27,12 @@ breadpack
`breadpack list [--json] [--upgradable]`
This list is generated from the cached version data on `breadpunk-lock.json`.
An admin will need to run `breadpack check` to update this list.
* `--json` enables JSON output, useful for scripting.
* `--upgradable` restricts the output to upgradable packages only.
### request a package
`breadpack request <package_name> [-c|--comment COMMENT]`
@ -173,3 +181,9 @@ The model descriptor can be written as follows:
%sort: Processed Date
%doc: Package requests made via breadpack
```
## setup
### dependencies
For APT support, install `python3-apt`.

View File

@ -1,11 +1,14 @@
#!/usr/bin/env python3
from abc import ABC, ABCMeta, abstractmethod
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional
import argparse
import logging
import os
import pwd
import sys
logging.basicConfig(
level=logging.INFO,
@ -14,13 +17,13 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
def _get_username():
def _get_username() -> str:
return pwd.getpwuid(os.getuid()).pw_name
# The recfile descriptor for package requests.
# Used if Breadpack generates the file from scratch.
REQUEST_DESCRIPTOR = """
REQUEST_DESCRIPTOR: str = """
%rec: PackageRequest
%type: Date date
%type: Processed bool
@ -33,11 +36,103 @@ REQUEST_DESCRIPTOR = """
""".strip()
class NotInstalled(Exception):
"""
This exception should be raised by local package version checking
if it determines the package is not currently installed.
This allows detecting uninstalled packages.
"""
_package_checkers = {}
class PackageCheckerMetaclass(type):
def __new__(cls, *args, register: bool = True, package_type: str = None, **kwargs):
newclass = super().__new__(cls, *args, **kwargs)
if register:
assert isinstance(package_type, str), 'Package type is required'
_package_checkers[package_type] = newclass
return newclass
class PackageCheckerABC(PackageCheckerMetaclass, ABCMeta): ...
class PackageChecker(metaclass=PackageCheckerABC, register=False):
@classmethod
@abstractmethod
def get_current_version(cls, package_data: dict) -> str: ...
@classmethod
@abstractmethod
def get_latest_version(cls, package_data: dict) -> str: ...
class APTPackageChecker(PackageChecker, package_type='apt'):
_apt_cache = None
@classmethod
def _get_package(cls, package_data: dict):
if not cls._apt_cache:
import apt
cls._apt_cache = apt.Cache()
package_name = package_data.get('apt_name', package_data['name'])
return cls._apt_cache[package_name]
@classmethod
def get_current_version(cls, package_data: dict) -> str:
package = cls._get_package(package_data)
if not package.is_installed:
raise NotInstalled
return package.installed.version
@classmethod
def get_latest_version(cls, package_data: dict) -> str:
return cls._get_package(package_data).versions[0].version
class JsonDocument(ABC):
@classmethod
def from_file(cls, path: Path) -> 'JsonDocument':
with path.open() as f:
return cls(**json.load(f))
class PackageFile(JsonDocument):
def __init__(self, version, categories, packages) -> None:
self.version = int(version)
assert self.version == 0, f'Incompatible file version {version} (expected 0)'
assert isinstance(categories, dict), 'categories should be an object'
assert all(isinstance(str, v) for v in categories.values()), \
'categories should map string keys to string descriptions'
self.categories = categories
assert isinstance(packages, list), 'packages should be a list'
assert all(isinstance(package, dict) for package in packages)
self.packages = packages
def pretty_print_package(package_data: dict):
print('{name} ({type}) - current: {current_version}, latest: {latest_version}'.format(**package_data))
###############################################################################
# COMMANDS
###############################################################################
def request_subcommand(
requestsfile: Path,
package_name: str,
comment: Optional[str] = None,
**kwargs):
**kwargs) -> int:
if not requestsfile.exists():
logger.warning(f'Creating file {requestsfile}')
with requestsfile.open('w') as f:
@ -56,25 +151,48 @@ def request_subcommand(
f.write('\n\n' + '\n'.join([f'{k}: {v}' for k, v in data.items()]))
logger.info(f'Your request for {package_name!r} has been sent!')
return 0
def list_subcommand(json: bool = False, upgradable: bool = False, **kwargs):
def list_subcommand(json: bool = False, upgradable: bool = False, lockfile: Path = None, **kwargs) -> int:
if not lockfile.is_file():
logger.error(f'{lockfile} not found. Ask an admin to run `breadpack check` '
'or ensure that you have read access to this file.')
return 1
packages = PackageFile.from_file(lockfile).packages
if upgradable:
packages = [package for package in packages
if package.get('latest_version')
and package['latest_version'] != package['current_version']]
if json:
print(json.dumps(packages))
else:
for package in packages:
pretty_print_package(package)
return 0
def lint_subcommand(file: Path = None, lockfile: Path = None, **kwargs) -> int:
errored = False
for path in (file, lockfile):
logger.info(f'Linting {path}')
try:
PackageFile.from_file(path)
except Exception as e:
logger.error(f'Failed reading file {path}: {e}')
errored = True
return errored
def check_subcommand(json: bool = False, save: bool = True, **kwargs) -> int:
raise NotImplementedError
def lint_subcommand(file, **kwargs):
def lock_subcommand(**kwargs) -> int:
raise NotImplementedError
def check_subcommand(json: bool = False, save: bool = True, **kwargs):
raise NotImplementedError
def lock_subcommand(**kwargs):
raise NotImplementedError
def main():
def main() -> int:
parser = argparse.ArgumentParser(
description='Breadpunk.club meta-package manager'
)
@ -97,7 +215,7 @@ def main():
default=Path('/bread/breadpack-requests.rec'),
)
def nocommand(**kwargs):
def nocommand(**kwargs) -> None:
parser.error('A subcommand is required.')
parser.set_defaults(func=nocommand)
@ -175,8 +293,8 @@ def main():
)
args = vars(parser.parse_args())
args.pop('func', nocommand)(**args)
return args.pop('func', nocommand)(**args)
if __name__ == '__main__':
main()
sys.exit(main())