Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,24 +24,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Setup Python (3.7)
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
uses: actions/checkout@v5
- name: Setup Python (3.10)
uses: actions/setup-python@v6.1.0
with:
python-version: "3.7"
python-version: "3.10"
architecture: x64
- name: Install PDM
run: pip install setuptools wheel pdm
uses: pdm-project/setup-pdm@v4.4
- name: Cache Venv
uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0
uses: actions/cache@v5.0.1
with:
path: .venv
key: venv-${{ hashFiles('pdm.lock') }}
restore-keys: |
venv-
- name: Provision Environment
run: pdm sync -dG:all
run: pdm install --dev -G all
if: steps.cache-venv.outputs.cache-hit != 'true'
- name: Synchronize Environment
run: pdm sync --dev -G all
if: steps.cache-venv.outputs.cache-hit == 'true'
- name: Lint
run: pdm lint-check
- name: Check Formatting
Expand All @@ -56,19 +59,19 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11", "3.12" ]
python-version: [ "3.10", "3.11", "3.12", "3.13", "3.14" ]
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@v5
- name: Setup Python (${{ matrix.python-version }})
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
uses: actions/setup-python@v6.1.0
with:
python-version: ${{ matrix.python-version }}
architecture: x64
cache: pip
- name: Cache Venv
id: cache-venv
uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4.0.0
uses: actions/cache@v5.0.1
with:
path: .venv
key: venv-${{ hashFiles('pdm.lock') }}
Expand All @@ -77,8 +80,11 @@ jobs:
- name: Install PDM
run: pip install setuptools wheel pdm
- name: Provision Environment
run: pdm sync -dG:all
run: pdm install --dev -G all
if: steps.cache-venv.outputs.cache-hit != 'true'
- name: Synchronize Environment
run: pdm sync --dev -G all
if: steps.cache-venv.outputs.cache-hit == 'true'
- name: Unit Tests
run: pdm test

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ jobs:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@v5
- name: Install PDM
uses: pdm-project/setup-pdm@b2472ca4258a9ea3aee813980a0100a2261a42fc # v4.2
uses: pdm-project/setup-pdm@v4.4
- name: Build Distribution(s)
run: pdm build --no-sdist
- name: Publish Distribution(s)
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,45 @@ starting in version `1.y.z` `pkgps` types like `NVR` and its higher precision co
unpack dimension-wise from left-to-right e.g. `name, version, release` for `NVR`,
`name, epoch, version, release, architecture` for `NEVRA` etc and this is guaranteed not to
change within major version `1`.

## Installation

Install `pkgps` with base functionality, use:

```shell
pip install pkgps
```

## Extensions
For convenience `pkgps` ships with built-in extensions of each type for your favorite data class
library including:

* [`attrs`][_attrs]
* [`pydantic`][_pydantic]

### Installing with `attrs` support

To install `pkgps` with `attrs` extensions enabled, use:

```shell
pip install pkgps[attrs]
```

### Installing with `pydantic` support

To install `pkgps` with `pydantic` extensions enabled, use:

```shell
pip install pkgps[pydantic]
```

### Installing with all extensions

To install `pkgps` with all extensions enabled, use:

```shell
pip install pkgps[all]
```

[_attrs]: https://www.attrs.org/en/stable/
[_pydantic]: https://docs.pydantic.dev/latest/
1,079 changes: 636 additions & 443 deletions pdm.lock

Large diffs are not rendered by default.

21 changes: 12 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ description = "Package coordinates as types."
authors = [
{name = "Ryan Smith", email = "ryan.d.smith.106@gmail.com"},
]
dependencies = [
"attrs>=24.2.0",
]
requires-python = ">=3.9"
dependencies = ["collektions>=1.1.1"]
requires-python = ">=3.10"
readme = "README.md"
license = {text = "MIT"}
dynamic = ["version"]

[project.optional-dependencies]
pydantic = ["pydantic"]
pydantic = ["pydantic", "typing-extensions ; python_version < '3.13'"]
attrs = ["attrs"]
all = ["attrs", "pydantic"]

[build-system]
requires = ["pdm-backend"]
Expand Down Expand Up @@ -44,10 +44,6 @@ dev = [
"bandit>=1.7.5",
"mypy>=1.4.1",
]
test = [
"pytest>=7.4.4",
"pytest-cov>=4.1.0",
]

[tool.bandit]
exclude_dirs = [".venv", ".*_cache", ".pdm-build", "docs", "htmlcov", ".gitlab"]
Expand Down Expand Up @@ -189,3 +185,10 @@ docstring-code-format = true
# This only has an effect when the `docstring-code-format` setting is
# enabled.
docstring-code-line-length = "dynamic"

[dependency-groups]
test = [
"pytest>=7.4.4",
"pytest-cov>=4.1.0",
"pkgps[all]",
]
6 changes: 3 additions & 3 deletions src/pkgps/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
__all__ = ["MalformedCoordinates", "NEVR", "NEVRA", "NVR", "NVRA"]
__all__ = ["NEVR", "NEVRA", "NVR", "NVRA", "MalformedCoordinates"]

from ._exceptions import MalformedCoordinates
from .nvr import NEVR, NEVRA, NVR, NVRA

try:
from .version import __version__
except ImportError:
# version.py is generated at build time
# if for some reason it's missing use the same fallback PDM uses
__version__ = "0.0.0"

from ._impl import NEVR, NEVRA, NVR, NVRA, MalformedCoordinates
2 changes: 0 additions & 2 deletions src/pkgps/_exceptions.py

This file was deleted.

13 changes: 13 additions & 0 deletions src/pkgps/_impl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
__all__ = [
"NEVR",
"NEVRA",
"NVR",
"NVRA",
"MalformedCoordinates",
]

from .exc import MalformedCoordinates
from .nevr import NEVR
from .nevra import NEVRA
from .nvr import NVR
from .nvra import NVRA
11 changes: 11 additions & 0 deletions src/pkgps/_impl/attrs/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
__all__ = [
"NEVR",
"NEVRA",
"NVR",
"NVRA",
]

from .nevr import NEVR
from .nevra import NEVRA
from .nvr import NVR
from .nvra import NVRA
61 changes: 61 additions & 0 deletions src/pkgps/_impl/attrs/nevr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from __future__ import annotations

__all__ = ["NEVR"]

from attrs import asdict, evolve, field, frozen
from attrs.validators import matches_re
from typing_extensions import Self

from ..parse import parse_nevr, parse_nevr_or_none
from .nvr import NVR


@frozen(kw_only=True)
class NEVR:
"""A class representing build/package name-epoch:version-release coordinates.

This class supports iteration, allowing iterable unpacking behavior in
assignment expressions, e.g.:

```python
nevr = NEVR.from_string("curl-1:7.76.1-26.el9")
name, epoch, version, release = nevr
print(f"{name=} {epoch=} {version=} {release=}")
# name=curl epoch=1 version=7.76.1 release=26.el9
```
"""

name: str = field(
validator=matches_re(
r"^[^<>=\s{}%]+$",
)
)
"""Name. Must not contain: < > = whitespace { } %"""
epoch: int = 0
"""Epoch."""
version: str
"""Version."""
release: str
"""Release."""

copy = evolve
to_dict = asdict

@classmethod
def from_string(cls, nevr: str) -> Self:
return parse_nevr(nevr, cls)

@classmethod
def from_string_or_none(cls, nevr: str | None) -> Self | None:
return parse_nevr_or_none(nevr, cls)

def __str__(self) -> str:
epoch_string = f"{self.epoch}:" if self.epoch else ""
return f"{self.name}-{epoch_string}{self.version}-{self.release}"

def __iter__(self):
return iter((self.name, self.epoch, self.version, self.release))

def to_nvr(self) -> NVR:
"""Convert to NVR by discarding epoch information."""
return NVR(name=self.name, version=self.version, release=self.release)
81 changes: 81 additions & 0 deletions src/pkgps/_impl/attrs/nevra.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

__all__ = ["NEVRA"]

from attrs import asdict, evolve, field, frozen
from attrs.validators import matches_re

from ..parse import parse_nevra, parse_nevra_or_none
from .nevr import NEVR
from .nvr import NVR
from .nvra import NVRA


@frozen(kw_only=True)
class NEVRA:
"""A class representing build/package name-epoch:version-release.architecture coordinates.

This class supports iteration, allowing iterable unpacking behavior in
assignment expressions, e.g.:

```python
nevra = NEVRA.from_string("curl-1:7.76.1-26.el9.aarch64")
name, epoch, version, release, arch = nevra
print(f"{name=} {epoch=} {version=} {release=} {arch=}")
# name=curl epoch=1 version=7.76.1 release=26.el9 arch=aarch64
```
"""

name: str = field(
validator=matches_re(
r"^[^<>=\s{}%]+$",
)
)
"""Name. Must not contain: < > = whitespace { } %"""
epoch: int = 0
"""Epoch."""
version: str
"""Version."""
release: str
"""Release."""
arch: str
"""Architecture."""

copy = evolve
to_dict = asdict

@classmethod
def from_string(cls, nevra: str) -> NEVRA:
return parse_nevra(nevra, cls)

@classmethod
def from_string_or_none(cls, nevra: str | None) -> NEVRA | None:
return parse_nevra_or_none(nevra, cls)

def __str__(self) -> str:
epoch_string = f"{self.epoch}:" if self.epoch else ""
return f"{self.name}-{epoch_string}{self.version}-{self.release}.{self.arch}"

def __iter__(self):
return iter((self.name, self.epoch, self.version, self.release, self.arch))

@property
def architecture(self) -> str:
"""An alias of `arch` for those who prefer the long-form name."""
return self.arch

def to_nvr(self) -> NVR:
"""Convert to NVR by discarding epoch and architecture information."""
return NVR(name=self.name, version=self.version, release=self.release)

def to_nevr(self) -> NEVR:
"""Convert to NEVR by discarding architecture information."""
return NEVR(
name=self.name, epoch=self.epoch, version=self.version, release=self.release
)

def to_nvra(self) -> NVRA:
"""Convert to NVRA by discarding epoch information."""
return NVRA(
name=self.name, version=self.version, release=self.release, arch=self.arch
)
Loading