Compare commits

..

15 Commits

Author SHA1 Message Date
7b23ab91be
Removed instance check 2023-08-12 16:42:43 +01:00
c20a1fb1c4
Removed TYPE_CHECKING 2023-08-12 16:39:30 +01:00
353dc3f768
Updated transaction.py
When computing, the package is added to the final state.

Added more logging statements

Fixed bugs for annotations
2023-08-12 16:37:05 +01:00
8dc0e10969
Updated operations.py
Operation functions now take the transaction object as an input.
Annotation is provided using the TYPE_CHECKING constant.

Added more logging statements.
2023-08-12 16:35:33 +01:00
f312a85353
Updated util.py
Fixed bug where header was being written to file when last log removed
newline.
2023-08-12 16:34:17 +01:00
18634e7794
Added transaction.py
Allow for transactions to be created, controlling and managing operation
goals.

enum@PkgGoal:
	- An enumaration of all possible package goals which a transaction
	  can perform.

class@Transaction:
	- Create a new transaction for the given context of a comment
	  (specified by the `goal=`).
	- method@compute:
		- Compute all the system dependencies and return them as a
		  tuple. Note, dependencies that soypak can install will not be
		  listed here and included in the final state.
	- method@apply:
		- Will apply the goal to the transaction and handle the
		  appropriate operations call.
	- method@problems:
		- Report a tuple of packages which were identified as
		  problematic during the transaction goal.
	- method@commit:
		- Commit the transaction to record to inform what exactly
		  happened during that transaction, i.e., the goal.
	-method@finalised:
		- Return a list of the final state, i.e., those packages related
		  to the transaction goal.
2023-08-12 00:26:56 +01:00
7407ba1084
Added util.py
Provide a bunch of utilities for soypak.

Specify compiled regexes for bottle file parsing.

func@ensure_dir:
	- Will ensure that a directory that exists, and if it doesn't, will
	  create it and any parents. Mode is 0755.
	- From DNF util module.

func@join_path:
	- Will join any number of string paths together and return a new
	  string path.
	- From eopkg util module.

func@rack_bottle:
	- Function will aim to write a new bottle file out to the database
	  (nickname: rack).
	- Either a string representation of a bottle file can be given, or a
	  file descriptor of type TextIOWrapper, can be given as the file
	  pointer:
		- if a TextIOWrapper, ensure that the pointer is at the start of
		  the file to read the contents again.
		- if a string, the contents can be set at this.
	- Detect if there are any comments (useful from translating user
	  defined bottles to be stored - Packages file won't have this). If
	  so, remove them.
	- Ensure that the database rack exists.
	- Create the new filename to be stored.
	- Attempt to open the file in write mode and write the contents to
	  file. Report on the amount of bytes written for clarity.
	- Function returns true if the file was successfully racked. False
	  upon any failures.
2023-08-12 00:17:28 +01:00
4315780777
Added operations.py
Define all operations which soypak can do. Early development of func@install_packages
2023-08-12 00:14:37 +01:00
683e55ea0d
Added bottler.py
Parse bottle files and constructure a package class which can be used in
transactions.

Set of valid packages keys is defined.

func@_parse_bottle_file:
	- Module protected function called internally to parse bottle files.
	- Extract the Description field (this is a little different from
	  other fields given it can be multiline).
	- Remove any comments which may have been left by a user.
	- Process the bottle file and extract all key-value pairs. This will
	  also ensure that mandatory fields exist, and that any fields which
	  are repeated are flagged (parser will cause app to crash).
	- Return a dictionary of key-value pairs.

class@_Soybottle:
	- Class (struct) which represents a bottle file in a structured
	  format. The dictionary is unwrapped after parsing and populates
	  this class using the `.loadPackage()` classmethod.
	- Defined repr dunder which will return a string representation of
	  the class as a dictionary (note class uses slots).
	- method@loadPackage():
		- Defines a set of mandatory fields required in the bottle file.
		- Reports and exits if mandatory fields are missing. Warn if
		  unrecognised fields exist.
		- Separate optional fields from mandatory ones.
		- Construct new class instance and return the object.

class@PackageItem:
	- Class which stores information regarding a package, including
	  whether it is installed, and the package information from
	  _Soybottle. Class is early development.

func@loadPackage:
	- This function is akin to _Soybottle@loadPackage, but *should* be
	  called instead of the classmethod. To be called when a bottle file
	  is given as argv, not by bottle file in the database.
	- Given a filename (`item=`), will attempt to open and read the
	  contents of a bottle file. If the file is readable, it will then
	  begin parsing and constructing a new class@PackageItem object.
	- Function will attempt to permanently store a bottle file to
	  database. If this fails, the app will sys-exit.
	- Either the class@PackageItem or Exception is returned. This is to
	  allow for exception messages to be given back to the install
	  command.
2023-08-11 23:46:22 +01:00
851e37538c
Updated install.py
Now uses transaction module.

Conditional statement changed for checking if an argv is a bottle file:
	- Initially check for file suffix. Inform if file detected.
	- Then check if bottle file exists on the system. Report
	  accordingly.
	- Attempt to load the bottle file as a package. Fail if otherwise
	  unable to do this. Otherwise inform that it was loaded okay.

Check if we have anything to install. Inform and exit if we have
nothing.

Start a new transaction:
	- This is to give greater control and ensure the goal is met. A
	  transaction is initialised with a goal, and `.apply()` will
	  perform this goal.
	- Compute the system dependencies with `.compute()`. Any
	  dependencies which soypak can install (recognised in the packages
	  database), will not be included here. Report if we have
	  dependencies.
	- Apply the transaction to the system with `.apply()`. This will be
	  dependent on the goal set.
	- Check for problems when applying the transaction with `.problems()`.
	- Finally commit the transaction to record.
2023-08-11 23:35:59 +01:00
2bd65d19d4
Added spec.bottle
The bottle file specification.
2023-08-11 23:34:51 +01:00
800e7973c3
Added __init__.py
Variable for database record stores.
2023-08-11 23:34:18 +01:00
82a7224190
Added install command
Added the install command.
	- Implemented run method.
	- A list of candidates to install is determined.
	- Basic implementation side loads packages
2023-08-09 14:07:49 +01:00
c8848aec47
Updated soypakcli.py 2023-08-09 14:05:40 +01:00
63d6c4b31c
Updated logging in command.py 2023-08-07 14:42:59 +01:00
10 changed files with 403 additions and 83 deletions

View File

@ -1,79 +1,15 @@
![soypak logo](https://git.closedless.xyz/ClosedLess/soypak/raw/branch/11435da41b/assets/soypack-logo.svg) ![soypak logo](./assets/soypack-logo.svg)
# soypak # soypak
An additional package manager for managing Debian apps on Linux distros.
This branch is an early prototype to demonstrate the CLI interaction with soypak, An additional package manager for managing Microsoft apps on Linux distros.
and provides a vision of what is to be created. It is not an early prototype which
can perform package management. With that, there may be typos and other bugs.
## Setup & Installation ## TODO
This branch can be cloned with the following command.
```
git clone --single-branch --branch 11435da41b https://git.closedless.xyz/ClosedLess/soypak.git
cd soypak/
```
Afterwards, a virtual environment should be created to prevent system-wide pip - [x] CLI Parser
installation. - [x] Logging
``` - [ ] Error system
python3 -m venv <virtualenv> - [ ] Translation system (I18N and PO files)
``` - [ ] CLI UI
- [ ] Package installer
Then the following pip installation commands can be ran - [ ] Dependency resolver
``` - [ ] Package Plug-in Manager
pip3 install -e .
pip3 install clog-x.x.xxx-py3-none-any.whl # where x.x.xxx is the latest version release
```
You can fetch the latest version of CLog from
[here](https://git.closedless.xyz/ClosedLess/clog/releases). Either the `.whl` or `tar.gz` file.
## Using soypak
After setting up soypak, the app can be ran using the Python interpreter
```
python3 soypak/soypak-cli.py help
```
This will print the help message to the terminal and exit. Here, the `help` command
is used explicitly, however the same can be achieved by providing no commands (an
additional error message will also be printed at the top in red).
The following is the output printed
```
Usage: soypak [options] <command> [arguments]
A package manager to manage Microsoft apps.
Where <command> is one of:
help Display the help message of the give command(s), or print this message.
Options:
-h, --help show this help message and exit
--version show the program's version number and exit
Author(s): Ethan Smith-Coss.
Contacts: ethan.sc@closedless.xyz
Thanks for using our software 🍪
Copyright (C) ClosedLess 2023. Licensed under GNU GPLv3+
```
As stated in the printed message, `help` provides the help message of a given command.
With this, we can pass 'help' as an argument to `help`
```
python3 soypak/soypak-cli.py help help
```
This will produce the following output
```
help: Display the help message of the give command(s), or print this message.
Usage: Usage: help [<command1>, <command2>, ..., <commandn>]
If no arguments are specified, the general help message is displayed.
```
Help messages are structured in the following format:
- `<command name>: <synopsis>` - the name of the command and its shortest description
- `<usage>` - how to use the command in question
- `<summary>` - an extended description of the command

View File

@ -4,8 +4,7 @@ import clog
import soypak.cli.parser as parser import soypak.cli.parser as parser
# partial load the logger log = clog.Logger.get("runtime_logger")
log = lambda: clog.Logger.get("runtime_logger")
class CommandHelpStruct: class CommandHelpStruct:
@ -35,7 +34,7 @@ class Command:
if error: if error:
# Just get the logger for this one statement (:@Note: if more logging occurs, global variable for logger) # Just get the logger for this one statement (:@Note: if more logging occurs, global variable for logger)
log().error("Unrecognised command: %s" % name).withConsole() log.error("Unrecognised command: %s" % name).withConsole()
Command.die() Command.die()
return None return None
@ -60,13 +59,13 @@ def register_command(_cls=None, *, name: str, alias=None, help: CommandHelpStruc
# check if the command has already been given a registry name (only one should exist) # check if the command has already been given a registry name (only one should exist)
if hasattr(cls, 'name'): if hasattr(cls, 'name'):
msg = f"Command {cls.__name__!r} has already been registered with the given name: {getattr(cls, 'name')!r}" msg = f"Command {cls.__name__!r} has already been registered with the given name: {getattr(cls, 'name')!r}"
log().error(msg) log.error(msg)
raise Exception(msg) raise Exception(msg)
# check if that registry name is already taken # check if that registry name is already taken
if name in Command.registry_names: if name in Command.registry_names:
msg = f"Command {cls.__name__!r} cannot use registry name {name!r} because it is in use." msg = f"Command {cls.__name__!r} cannot use registry name {name!r} because it is in use."
log().error(msg) log.error(msg)
raise Exception(msg) raise Exception(msg)
# set the name of the command as given # set the name of the command as given
@ -74,13 +73,13 @@ def register_command(_cls=None, *, name: str, alias=None, help: CommandHelpStruc
# This is deliberate to make aware that `alias` isn't being used so pointless to pass now. # This is deliberate to make aware that `alias` isn't being used so pointless to pass now.
if alias is not None: if alias is not None:
log().warn(f"'alias' argument was passed as something other than None. This is intended for future use.") log.warn(f"'alias' argument was passed as something other than None. This is intended for future use.")
raise Warning("The 'alias' argument is not intended for use. Please exclude for now.") raise Warning("The 'alias' argument is not intended for use. Please exclude for now.")
# ensure that the `help` given is of the correct type # ensure that the `help` given is of the correct type
if not isinstance(help, CommandHelpStruct): if not isinstance(help, CommandHelpStruct):
msg = f"The argument 'help' must be of type CommandHelpStruct, not {type(help)!r}" msg = f"The argument 'help' must be of type CommandHelpStruct, not {type(help)!r}"
log().error(msg) log.error(msg)
raise TypeError(msg) raise TypeError(msg)
# construct the help message # construct the help message

View File

@ -0,0 +1,76 @@
import sys
import clog
import pathlib
import argparse
import soypak.cli.command as command
import soypak.deps.transaction as transaction
import soypak.deps.bottler as bottler
_log = clog.Logger.get("runtime_logger")
@command.register_command(name="install", help=command.CommandHelpStruct(
synopsis="Install debian binary packages.",
usage="install <package1> [<package2>, ..., <packagen>]",
summary=("Install one or multiple available packages via soypak."))
)
class Install(command.Command):
def __init__(self, *, data: argparse.Namespace, args: list[str] = []) -> None:
super().__init__()
self.data = data
self.rargs = args
def run(self):
candidates: list[bottler.PackageItem] = []
# any preinit before installing should occur here
for item in self.rargs:
# has a bottle file been directly passed?
if item.endswith(".bottle"):
_log.debug("Detected a bottle file. Attempting to sideload...", end="").withConsole()
_log.writeLog(header=False)
# check if the file actually exists on the system
if not pathlib.Path(item).resolve().exists():
_log.printLog("failed.", level=4)
_log.warn(f"Could not find bottle file {item!r}. Skipping package.").withConsole()
continue
# let's structure the file to be processed.
pkg = bottler.loadPackage(item)
if isinstance(pkg, Exception):
_log.printLog("failed.", level=4)
# :@Ethan: maybe the loadPackage could give us some information.
_log.error(f"Unable to sideload bottle file {item!r}. The following error was raised: {str(pkg)}")
sys.exit(1)
_log.printLog("ok.", level=1)
_log.debug(f"Sideloaded: {item}")
candidates.append(pkg)
continue
# let's check the database for a package by the given name
...
if not candidates:
_log.info("Nothing to install.").withConsole()
sys.exit(0)
tx = transaction.Transaction(transaction.PkgGoal.PKG_INSTALL)
depends = tx.compute(candidates)
if len(depends) > 0:
_log.debug("Computed system dependencies required by the packages selected.\n", *depends)
_log.printLog("The following system dependencies have been identified:\n",
*depends, "", level=3, sep=" | ")
tx.apply()
if not tx.problems():
...
# :@TODO: handle all the problems
# commit the transaction to record
tx.commit()

View File

@ -4,7 +4,8 @@ import clog
import soypak.cli.command as command import soypak.cli.command as command
# import all the commands to auto-register # import all the commands to auto-register
from soypak.cli.commands import ( from soypak.cli.commands import (
help help,
install,
) )
import soypak.cli.parser as parser import soypak.cli.parser as parser

1
soypak/db/__init__.py Normal file
View File

@ -0,0 +1 @@
rack_db_dir = "/var/db/soypak/rack"

120
soypak/deps/bottler.py Normal file
View File

@ -0,0 +1,120 @@
import re
import sys
import clog
import soypak.util as util
from io import TextIOWrapper
_log = clog.Logger.get("runtime_logger")
_PACKAGE_KEYS = {"Package", "Version", "Maintainer", "Filename", "Package-Repo", "Description", "Section",
"Priority", "Essential", "Architecture", "Origin", "Bugs", "Homepage", "Tag", "Depends",
"Pre-Depends", "Recommends", "Suggests", "Breaks", "Conflicts", "Replaces", "Provides"}
def _parse_bottle_file(item: TextIOWrapper) -> dict[str, str]:
# read the contents directly into memory
contents = item.read()
# first grab the description (if exists), then remove it
desc = list(re.finditer(util.re_desc_tag, contents))
contents = re.sub(util.re_desc_tag, "", contents)
# clean out the comments, strip leading/trailing whitespace, and split the lines
contents = re.sub(util.re_comments, "", contents).strip().splitlines()
parameters = {}
# handle for description
if not desc:
_log.error("Missing field from bottle file, 'Description'.").withConsole()
sys.exit(-1)
# format, clean and add to parameters dictionary
desc = str(desc[0].group()).split("\n")
parameters['Description'] = (desc[0].lstrip("Description:").strip(), "\n".join(desc[1:]))
for param in contents:
# handle for when newlines were split (empty string)
if not param:
continue
# split the key-value and strip it
try:
key, val = map(lambda _: _.strip(), param.split(':'))
except ValueError:
# we should stop processing the file
_log.error("The bottle file contains broken fields.").withConsole()
sys.exit(-1)
if key in parameters:
# The bottle file has a parameter repeated multiple times. This is parsing error
_log.error(f"Parameter {key!r} occurs more than once in bottle file.").withConsole()
sys.exit(-1)
if key not in _PACKAGE_KEYS:
_log.error(f"Parameter {key!r} is not a valid field for bottle files.").withConsole()
sys.exit(-1)
# now we can safely add the key to the dictionary
parameters[key] = val
return parameters
class _Soybottle:
__slots__ = "package", "version", "maintainer", "desc", "package_repo", "filename", "optional"
def __init__(self, *, pkg, version, maintainer, desc, pkg_repo, filename, optionals={}) -> None:
self.package = pkg
self.version = version
self.maintainer = maintainer
self.desc = desc
self.package_repo = pkg_repo
self.filename = filename
self.optional = optionals
def __repr__(self) -> str:
return '{' + ", ".join("{0!r}: {1!r}".format(k, getattr(self, k)) for k in type(self).__slots__) + '}'
@classmethod
def loadPackage(cls, *, fields: dict[str, str]):
REQUIRED = {"Package", "Version", "Maintainer", "Description", "Package-Repo", "Filename"}
try:
for k in REQUIRED:
fields[k]
except KeyError as err:
_log.error("Missing mandatory key from bottle file %s" % str(err)).withConsole()
sys.exit(-1)
# grab all the optional fields from the bottle file
optionals = dict(map(lambda k: (k, fields.pop(k)), REQUIRED ^ _PACKAGE_KEYS))
if len(fields) > 6:
# just log a warning that the bottle file as some extra values unrecognised (not really critical)
_log.warn("Identified additional keys in bottle file that are unrecognised.")
return cls(pkg=fields['Package'], version=fields['Version'], maintainer=['Maintainer'], desc=['Description'],
pkg_repo=fields['Package-Repo'], filename=fields['Filename'], optionals=optionals)
class PackageItem:
def __init__(self, info: _Soybottle) -> None:
self.installed = False
self.pkg_info = info
def loadPackage(item: str) -> PackageItem | Exception:
try:
fp = open(item, 'r')
except (IOError, FileNotFoundError) as err:
return err
_log.debug(f"Obtained file point {fp!r}")
res = _Soybottle.loadPackage(fields=_parse_bottle_file(fp))
# permanently store the bottle file to the database
result = util.rack_bottle(fp, "")
if not result:
_log.error("The bottle file could not be permanently saved to soypak.").withConsole()
sys.exit(-1)
return PackageItem(res)

13
soypak/deps/operations.py Normal file
View File

@ -0,0 +1,13 @@
import clog
import soypak.deps.bottler as bottler
_log = clog.Logger.get("runtime_logger")
def install_packages(tx, pkgs: list[bottler.PackageItem]):
_log.debug(f"The transaction goal is {tx.pkg_goal=}. Attempting to perform installation.")
# :@TODO: install the damned things!
_log.debug("Installation of packages is complete. Ensure to resolve problems within the transaction.")

View File

@ -0,0 +1,54 @@
import enum
import sys
import clog
import soypak.deps.bottler as bottler
import soypak.deps.operations as ops
_log = clog.Logger.get("runtime_logger")
class PkgGoal(enum.Enum):
PKG_INSTALL = 1
class Transaction:
def __init__(self, goal: PkgGoal) -> None:
"""Setup a new transaction."""
if not isinstance(goal, PkgGoal):
_log.error(f"TypeError: {goal=}, expected PkgGoal.")
_log.printLog("There was an issue when operating on the transaction.", level=4)
sys.exit(1)
self.pkg_goal: PkgGoal = goal
def compute(self, pkgs: list[bottler.PackageItem]) -> tuple:
"""Compute dependencies and conflicts of packages. Returns a list of total packages to install."""
self._final_state = []
sys_depends = []
for pkg in pkgs:
# add the package itself to the final state since it can be installed
self._final_state.append(pkg)
# :@TODO: compute the dependencies
return tuple(sys_depends)
def apply(self):
if self.pkg_goal == PkgGoal.PKG_INSTALL:
ops.install_packages(self, self._final_state)
def problems(self) -> tuple:
"""Provide a report of conflicts and packages which cannot be resolved (dependencies not installable)."""
return ()
def commit(self):
"""Commit the transaction to record for what happened."""
pass
@property
def finalised(self):
return self._final_state

85
soypak/util.py Normal file
View File

@ -0,0 +1,85 @@
import os
import re
import clog
import errno
import soypak.db
from io import TextIOWrapper
_log = clog.Logger.get("runtime_logger")
re_comments = re.compile(r"(--.*$|\*--(\n.*?)+--\*)", re.MULTILINE)
re_desc_tag = re.compile(r"^Description:(?: .*\n)+", re.MULTILINE)
# from DNF util module (https://github.com/rpm-software-management/dnf/blob/master/dnf/util.py#L142)
def ensure_dir(dname) -> tuple[int, str]:
try:
os.makedirs(dname, mode=0o755)
except OSError as err:
if err.errno != errno.EEXIST or not os.path.isdir(dname):
return err.errno, str(err)
return 0, ""
# from eopkg util module (https://github.com/solus-project/package-management/blob/master/pisi/util.py#L293)
def join_path(a, *p) -> str:
"""Join two or more pathname components.
Python os.path.join cannot handle '/' at the start of latter components.
"""
for b in p:
b = b.lstrip('/')
if a == '' or a.endswith('/'):
a += b
continue
a += '/' + b
return a
def rack_bottle(fp: TextIOWrapper | str, f_name: str) -> bool:
"""Store a bottle file in the database and update package cache."""
if isinstance(fp, TextIOWrapper):
_log.debug(f"{fp=}, ensuring file is read from beginning...", end="")
try:
# ensure we are at the start of the file before reading
fp.seek(0)
c = fp.read()
except IOError as err:
_log.writeLog("failed.", header=False)
_log.error(f"Couldn't open file for reading: {str(err)}")
return False
finally:
_log.writeLog("ok.", header=False)
fp.close()
f_name = fp.name.split('/')[-1]
else:
_log.debug("fp is a string. The contents can be directly used.")
c = fp
# clean the file of any comments (specific to storing from user input)
if re.search(re_desc_tag, c):
_log.debug("Scrubbing bottle comments from string content.")
c = re.sub(re_desc_tag, "", c)
result = ensure_dir(soypak.db.rack_db_dir)
if result[0] != 0:
_log.error(f"There was an issue when ensuring directory (exit code: {result[0]}): {result[1]}")
return False
new_file = join_path(soypak.db.rack_db_dir, f_name)
_log.debug(f"Opening new file to write bottle file to permanently: {new_file!r}")
try:
fp = open(new_file, 'w')
except IOError as err:
_log.error(f"There was an issue attempting to write to {new_file!r}: {str(err)}")
return False
fp.write(c)
s = fp.tell()
fp.close()
_log.debug("Successfully wrote %s bytes to file." % s)
return True

35
spec.bottle Normal file
View File

@ -0,0 +1,35 @@
-- Mandatory
Package: foo
Version: 0.1
Maintainer: Joe Bloggs
Filename: [uri|path-spec|ask|stdin]
Package-Repo: [url|local]
Description: <short description>
<long description>
-- Optional
Section: <section>
Priority: <priority>
*--
In Debian, the Section and Priority fields have a defined set of accepted values based on the Policy Manual. A list of
these values can be obtained from the latest version of the debian-policy package.
--*
Essential: <yes|no>
Architecture: <arch|all>
Origin: <name>
Bugs: <url>
Homepage: <url>
Tag: <tag list>
Depends: <package list>
Pre-Depends: <package list>
Recommends: <package list>
Suggests: <package list>
Breaks: <package list>
Conflicts: <package list>
Replaces: <package list>
Provides: <package list>
*--
Multiline comments.
ClosedLess (c) 2021-2024
--*