Compare commits

..

1 Commits

Author SHA1 Message Date
5aa72c4c71 in-house bytes pkgbuild parser 2025-05-05 16:32:46 +03:00
15 changed files with 137 additions and 273 deletions

View File

@ -8,10 +8,6 @@ on:
- '*'
- '!*rc*'
permissions:
contents: read
packages: write
jobs:
docker-image:

View File

@ -2,9 +2,6 @@ name: Regress
on: workflow_dispatch
permissions:
contents: read
jobs:
run-regress-tests:

View File

@ -5,9 +5,6 @@ on:
tags:
- '*'
permissions:
contents: write
jobs:
make-release:

View File

@ -8,9 +8,6 @@ on:
branches:
- master
permissions:
contents: read
jobs:
run-setup-minimal:

View File

@ -10,9 +10,6 @@ on:
schedule:
- cron: 1 0 * * *
permissions:
contents: read
jobs:
run-tests:

View File

@ -9,7 +9,13 @@ build:
python:
install:
- requirements: docs/requirements.txt
- method: pip
path: .
extra_requirements:
- docs
- s3
- validator
- web
formats:
- pdf

View File

@ -15,8 +15,9 @@ import sys
from pathlib import Path
from ahriman import __version__
# support package imports
basedir = Path(__file__).resolve().parent.parent / "src"
sys.path.insert(0, str(basedir))
@ -28,7 +29,6 @@ copyright = f"2021-{datetime.date.today().year}, ahriman team"
author = "ahriman team"
# The full version, including alpha/beta/rc tags
from ahriman import __version__
release = __version__
@ -91,13 +91,7 @@ autoclass_content = "both"
autodoc_member_order = "groupwise"
autodoc_mock_imports = [
"aioauth_client",
"aiohttp_security",
"aiohttp_session",
"cryptography",
"pyalpm",
]
autodoc_mock_imports = ["cryptography", "pyalpm"]
autodoc_default_options = {
"no-undoc-members": True,

View File

@ -1,128 +0,0 @@
# This file was autogenerated by uv via the following command:
# uv pip compile --group ../pyproject.toml:docs --extra s3 --extra validator --extra web --output-file ../docs/requirements.txt ../pyproject.toml
aiohappyeyeballs==2.6.1
# via aiohttp
aiohttp==3.11.18
# via
# ahriman (../pyproject.toml)
# aiohttp-cors
# aiohttp-jinja2
aiohttp-cors==0.8.1
# via ahriman (../pyproject.toml)
aiohttp-jinja2==1.6
# via ahriman (../pyproject.toml)
aiosignal==1.3.2
# via aiohttp
alabaster==1.0.0
# via sphinx
argparse-manpage==4.6
# via ahriman (../pyproject.toml:docs)
attrs==25.3.0
# via aiohttp
babel==2.17.0
# via sphinx
bcrypt==4.3.0
# via ahriman (../pyproject.toml)
boto3==1.38.11
# via ahriman (../pyproject.toml)
botocore==1.38.11
# via
# boto3
# s3transfer
cerberus==1.3.7
# via ahriman (../pyproject.toml)
certifi==2025.4.26
# via requests
charset-normalizer==3.4.2
# via requests
docutils==0.21.2
# via
# sphinx
# sphinx-argparse
# sphinx-rtd-theme
frozenlist==1.6.0
# via
# aiohttp
# aiosignal
idna==3.10
# via
# requests
# yarl
imagesize==1.4.1
# via sphinx
inflection==0.5.1
# via ahriman (../pyproject.toml)
jinja2==3.1.6
# via
# aiohttp-jinja2
# sphinx
jmespath==1.0.1
# via
# boto3
# botocore
markupsafe==3.0.2
# via jinja2
multidict==6.4.3
# via
# aiohttp
# yarl
packaging==25.0
# via sphinx
propcache==0.3.1
# via
# aiohttp
# yarl
pydeps==3.0.1
# via ahriman (../pyproject.toml:docs)
pyelftools==0.32
# via ahriman (../pyproject.toml)
pygments==2.19.1
# via sphinx
python-dateutil==2.9.0.post0
# via botocore
requests==2.32.3
# via
# ahriman (../pyproject.toml)
# sphinx
roman-numerals-py==3.1.0
# via sphinx
s3transfer==0.12.0
# via boto3
shtab==1.7.2
# via ahriman (../pyproject.toml:docs)
six==1.17.0
# via python-dateutil
snowballstemmer==3.0.0.1
# via sphinx
sphinx==8.2.3
# via
# ahriman (../pyproject.toml:docs)
# sphinx-argparse
# sphinx-rtd-theme
# sphinxcontrib-jquery
sphinx-argparse==0.5.2
# via ahriman (../pyproject.toml:docs)
sphinx-rtd-theme==3.0.2
# via ahriman (../pyproject.toml:docs)
sphinxcontrib-applehelp==2.0.0
# via sphinx
sphinxcontrib-devhelp==2.0.0
# via sphinx
sphinxcontrib-htmlhelp==2.1.0
# via sphinx
sphinxcontrib-jquery==4.1
# via sphinx-rtd-theme
sphinxcontrib-jsmath==1.0.1
# via sphinx
sphinxcontrib-qthelp==2.0.0
# via sphinx
sphinxcontrib-serializinghtml==2.0.0
# via sphinx
stdlib-list==0.11.1
# via pydeps
urllib3==2.4.0
# via
# botocore
# requests
yarl==1.20.0
# via aiohttp

View File

@ -25,64 +25,15 @@ dependencies = [
dynamic = ["version"]
[project.optional-dependencies]
journald = [
"systemd-python",
]
# FIXME technically this dependency is required, but in some cases we do not have access to
# the libalpm which is required in order to install the package. Thus in case if we do not
# really need to run the application we can move it to "optional" dependencies
pacman = [
"pyalpm",
]
reports = [
"Jinja2",
]
s3 = [
"boto3",
]
shell = [
"IPython"
]
stats = [
"matplotlib",
]
unixsocket = [
"requests-unixsocket2", # required by unix socket support
]
validator = [
"cerberus",
]
web = [
"aiohttp",
"aiohttp_cors",
"aiohttp_jinja2",
]
web_api-docs = [
"ahriman[web]",
"aiohttp-apispec",
"setuptools", # required by aiohttp-apispec
]
web_auth = [
"ahriman[web]",
"aiohttp_session",
"aiohttp_security",
"cryptography",
]
web_oauth2 = [
"ahriman[web_auth]",
"aioauth-client",
]
[project.scripts]
ahriman = "ahriman.application.ahriman:run"
[project.urls]
Documentation = "https://ahriman.readthedocs.io/"
Repository = "https://github.com/arcan1s/ahriman"
Changelog = "https://github.com/arcan1s/ahriman/releases"
[dependency-groups]
[project.scripts]
ahriman = "ahriman.application.ahriman:run"
[project.optional-dependencies]
check = [
"autopep8",
"bandit",
@ -96,6 +47,24 @@ docs = [
"shtab",
"sphinx-argparse",
"sphinx-rtd-theme>=1.1.1", # https://stackoverflow.com/a/74355734
]
journald = [
"systemd-python",
]
# FIXME technically this dependency is required, but in some cases we do not have access to
# the libalpm which is required in order to install the package. Thus in case if we do not
# really need to run the application we can move it to "optional" dependencies
pacman = [
"pyalpm",
]
s3 = [
"boto3",
]
shell = [
"IPython"
]
stats = [
"matplotlib",
]
tests = [
"pytest",
@ -106,6 +75,22 @@ tests = [
"pytest-resource-path",
"pytest-spec",
]
validator = [
"cerberus",
]
web = [
"Jinja2",
"aioauth-client",
"aiohttp",
"aiohttp-apispec",
"aiohttp_cors",
"aiohttp_jinja2",
"aiohttp_session",
"aiohttp_security",
"cryptography",
"requests-unixsocket2", # required by unix socket support
"setuptools", # required by aiohttp-apispec
]
[tool.flit.sdist]
include = [

View File

@ -23,7 +23,7 @@ import sys
from collections.abc import Generator, Mapping, MutableMapping
from string import Template
from typing import Any, ClassVar
from typing import ClassVar
from ahriman.core.configuration.shell_template import ShellTemplate
@ -85,7 +85,7 @@ class ShellInterpolator(configparser.Interpolation):
"prefix": sys.prefix,
}
def before_get(self, parser: MutableMapping[str, Mapping[str, str]], section: Any, option: Any, value: str,
def before_get(self, parser: MutableMapping[str, Mapping[str, str]], section: str, option: str, value: str,
defaults: Mapping[str, str]) -> str:
"""
interpolate option value
@ -100,8 +100,8 @@ class ShellInterpolator(configparser.Interpolation):
Args:
parser(MutableMapping[str, Mapping[str, str]]): option parser
section(Any): section name
option(Any): option name
section(str): section name
option(str): option name
value(str): source (not-converted) value
defaults(Mapping[str, str]): default values

View File

@ -95,6 +95,19 @@ class DuplicateRunError(RuntimeError):
self, "Another application instance is run. This error can be suppressed by using --force flag.")
class EncodeError(ValueError):
"""
exception used for bytes encoding errors
"""
def __init__(self, encodings: list[str]) -> None:
"""
Args:
encodings(list[str]): list of encodings tried
"""
ValueError.__init__(self, f"Could not encode bytes by using {encodings}")
class ExitCode(RuntimeError):
"""
special exception which has to be thrown to return non-zero status without error message

View File

@ -24,6 +24,7 @@ from pathlib import Path
from typing import Any, ClassVar, IO, Self
from ahriman.core.alpm.pkgbuild_parser import PkgbuildParser, PkgbuildToken
from ahriman.core.exceptions import EncodeError
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -33,13 +34,13 @@ class Pkgbuild(Mapping[str, Any]):
model and proxy for PKGBUILD properties
Attributes:
DEFAULT_ENCODINGS(str): (class attribute) default encoding to be applied on the file content
DEFAULT_ENCODINGS(list[str]): (class attribute) list of encoding to be applied on the file content
fields(dict[str, PkgbuildPatch]): PKGBUILD fields
"""
fields: dict[str, PkgbuildPatch]
DEFAULT_ENCODINGS: ClassVar[str] = "utf8"
DEFAULT_ENCODINGS: ClassVar[list[str]] = ["utf8", "latin-1"]
@property
def variables(self) -> dict[str, str]:
@ -57,13 +58,13 @@ class Pkgbuild(Mapping[str, Any]):
}
@classmethod
def from_file(cls, path: Path, encoding: str | None = None) -> Self:
def from_file(cls, path: Path, encodings: list[str] | None = None) -> Self:
"""
parse PKGBUILD from the file
Args:
path(Path): path to the PKGBUILD file
encoding(str | None, optional): the encoding of the file (Default value = None)
encodings(list[str] | None, optional): the encoding of the file (Default value = None)
Returns:
Self: constructed instance of self
@ -76,10 +77,15 @@ class Pkgbuild(Mapping[str, Any]):
content = input_file.read()
# decode bytes content based on either
encoding = encoding or cls.DEFAULT_ENCODINGS
io = StringIO(content.decode(encoding, errors="backslashreplace"))
encodings = encodings or cls.DEFAULT_ENCODINGS
for encoding in encodings:
try:
io = StringIO(content.decode(encoding))
return cls.from_io(io)
except ValueError:
pass
return cls.from_io(io)
raise EncodeError(encodings)
@classmethod
def from_io(cls, stream: IO[str]) -> Self:

View File

@ -3,7 +3,9 @@ import pytest
from io import BytesIO, StringIO
from pathlib import Path
from pytest_mock import MockerFixture
from unittest.mock import MagicMock
from ahriman.core.exceptions import EncodeError
from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@ -44,6 +46,18 @@ def test_from_file_latin(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> N
load_mock.assert_called_once_with(pytest.helpers.anyvar(int))
def test_from_file_unknown_encoding(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None:
"""
must raise exception when encoding is unknown
"""
open_mock = mocker.patch("pathlib.Path.open")
io_mock = open_mock.return_value.__enter__.return_value = MagicMock()
io_mock.read.return_value.decode.side_effect = EncodeError(pkgbuild_ahriman.DEFAULT_ENCODINGS)
with pytest.raises(EncodeError):
assert Pkgbuild.from_file(Path("local"))
def test_from_io(pkgbuild_ahriman: Pkgbuild, mocker: MockerFixture) -> None:
"""
must correctly load from io

View File

@ -1,8 +1,8 @@
import pytest
import pytest_asyncio
from aiohttp.test_utils import TestClient
from aiohttp.web import Application, Resource, UrlMappingMatchInfo
from asyncio import BaseEventLoop
from collections.abc import Awaitable, Callable
from marshmallow import Schema
from pytest_mock import MockerFixture
@ -164,13 +164,15 @@ def application_with_auth(configuration: Configuration, user: User, spawner: Spa
return application
@pytest_asyncio.fixture
async def client(application: Application, aiohttp_client: Any, mocker: MockerFixture) -> TestClient:
@pytest.fixture
def client(application: Application, event_loop: BaseEventLoop, aiohttp_client: Any,
mocker: MockerFixture) -> TestClient:
"""
web client fixture
Args:
application(Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
@ -178,35 +180,37 @@ async def client(application: Application, aiohttp_client: Any, mocker: MockerFi
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return await aiohttp_client(application)
return event_loop.run_until_complete(aiohttp_client(application))
@pytest_asyncio.fixture
async def client_with_auth(application_with_auth: Application, aiohttp_client: Any,
@pytest.fixture
def client_with_auth(application_with_auth: Application, event_loop: BaseEventLoop, aiohttp_client: Any,
mocker: MockerFixture) -> TestClient:
"""
web client fixture with full authorization functions
Args:
application_with_auth(Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return event_loop.run_until_complete(aiohttp_client(application_with_auth))
@pytest.fixture
def client_with_oauth_auth(application_with_auth: Application, event_loop: BaseEventLoop, aiohttp_client: Any,
mocker: MockerFixture) -> TestClient:
"""
web client fixture with full authorization functions
Args:
application_with_auth(Application): application fixture
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
Returns:
TestClient: web client test instance
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
return await aiohttp_client(application_with_auth)
@pytest_asyncio.fixture
async def client_with_oauth_auth(application_with_auth: Application, aiohttp_client: Any,
mocker: MockerFixture) -> TestClient:
"""
web client fixture with full authorization functions
Args:
application_with_auth(Application): application fixture
event_loop(BaseEventLoop): context event loop
aiohttp_client(Any): aiohttp client fixture
mocker(MockerFixture): mocker object
@ -215,4 +219,4 @@ async def client_with_oauth_auth(application_with_auth: Application, aiohttp_cli
"""
mocker.patch("pathlib.Path.iterdir", return_value=[])
application_with_auth[AuthKey] = MagicMock(spec=OAuth)
return await aiohttp_client(application_with_auth)
return event_loop.run_until_complete(aiohttp_client(application_with_auth))

46
tox.ini
View File

@ -1,9 +1,9 @@
[tox]
envlist = check, tests
isolated_build = true
isolated_build = True
labels =
release = version, docs, publish
dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2]
dependencies = -e .[journald,pacman,s3,shell,stats,validator,web]
project_name = ahriman
[mypy]
@ -24,13 +24,10 @@ commands =
[testenv:check]
description = Run common checks like linter, mypy, etc
dependency_groups =
check
deps =
{[tox]dependencies}
pip_pre = true
-e .[check]
setenv =
CFLAGS="-Wno-unterminated-string-initialization"
MYPYPATH=src
commands =
autopep8 --exit-code --max-line-length 120 -aa -i -j 0 -r "src/{[tox]project_name}" "tests/{[tox]project_name}"
@ -41,19 +38,16 @@ commands =
[testenv:docs]
description = Generate source files for documentation
allowlist_externals =
bash
find
mv
changedir = src
dependency_groups =
docs
depends =
version
deps =
{[tox]dependencies}
uv
pip_pre = true
-e .[docs]
changedir = src
allowlist_externals =
bash
find
mv
setenv =
SPHINX_APIDOC_OPTIONS=members,no-undoc-members,show-inheritance
commands =
@ -65,26 +59,22 @@ commands =
# remove autogenerated modules rst files
find ../docs -type f -name "{[tox]project_name}*.rst" -delete
sphinx-apidoc -o ../docs .
# compile list of dependencies for rtd.io
uv pip compile --group ../pyproject.toml:docs --extra s3 --extra validator --extra web --output-file ../docs/requirements.txt --quiet ../pyproject.toml
[testenv:html]
description = Generate html documentation
dependency_groups =
docs
deps =
{[tox]dependencies}
pip_pre = true
recreate = true
-e .[docs]
recreate = True
commands =
sphinx-build -b html -a -j auto -W docs {envtmpdir}{/}html
[testenv:publish]
description = Create and publish release to GitHub
allowlist_externals =
git
depends =
docs
allowlist_externals =
git
passenv =
SSH_AUTH_SOCK
commands =
@ -96,22 +86,18 @@ commands =
[testenv:tests]
description = Run tests
dependency_groups =
tests
deps =
{[tox]dependencies}
pip_pre = true
setenv =
CFLAGS="-Wno-unterminated-string-initialization"
-e .[tests]
commands =
pytest {posargs}
[testenv:version]
description = Bump package version
allowlist_externals =
sed
deps =
packaging
allowlist_externals =
sed
commands =
# check if version is set and validate it
{envpython} -c 'from packaging.version import Version; Version("{posargs}")'