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:
parent
607905241e
commit
8f56f362ef
14
README.md
14
README.md
|
@ -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`.
|
||||
|
|
152
breadpack.py
152
breadpack.py
|
@ -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())
|
||||
|
|
Reference in New Issue