Skip to main content

System package manager APIs in strongly typed Python

Project description

pydantic-pkgr         📦 apt  brew  pip  npm  ₊₊₊
Simple Pydantic interfaces for package managers + installed binaries.


PyPI Python Version Django Version GitHub GitHub Last Commit


It's an ORM for your package managers, providing a nice python types for packages + installers.

This is a Python library for installing & managing packages locally with a variety of package managers.
It's designed for when pip dependencies aren't enough, and your app has to check for & install dependencies at runtime.

pip install pydantic-pkgr

✨ Built with pydantic v2 for strong static typing guarantees and json import/export compatibility
📦 Provides consistent cross-platform interfaces for dependency resolution & installation at runtime
🌈 Integrates with django >= 4.0, django-ninja, and OpenAPI + django-jsonform out-of-the-box
🦄 Uses pyinfra / ansible for the actual install operations whenever possible (with internal fallbacks)

Built by ArchiveBox to install & auto-update our extractor dependencies at runtime (chrome, wget, curl, etc.) on macOS/Linux/Docker.


[!WARNING] This is BETA software, the API is mostly stable but there may be minor changes later on.

Source Code: https://github.com/ArchiveBox/pydantic-pkgr/

Documentation: https://github.com/ArchiveBox/pydantic-pkgr/blob/main/README.md


from pydantic_pkgr import *

apt, brew, pip, npm, env = AptProvider(), BrewProvider(), PipProvider(), NpmProvider(), EnvProvider()

dependencies = [
    Binary(name='curl',       binproviders=[env, apt, brew]),
    Binary(name='wget',       binproviders=[env, apt, brew]),
    Binary(name='yt-dlp',     binproviders=[env, pip, apt, brew]),
    Binary(name='playwright', binproviders=[env, pip, npm]),
    Binary(name='puppeteer',  binproviders=[env, npm]),
]
for binary in dependencies:
    binary = binary.load_or_install()

    print(binary.abspath, binary.version, binary.binprovider, binary.is_valid, binary.sha256)
    # Path('/usr/bin/curl') SemVer('7.81.0') AptProvider() True abc134...

    binary.exec(cmd=['--version'])   # curl 7.81.0 (x86_64-apple-darwin23.0) libcurl/7.81.0 ...
from pydantic import InstanceOf
from pydantic_pkgr import Binary, BinProvider, BrewProvider, EnvProvider

# you can also define binaries as classes, making them usable for type checking
class CurlBinary(Binary):
    name: str = 'curl'
    binproviders: List[InstanceOf[BinProvider]] = [BrewProvider(), EnvProvider()]

curl = CurlBinary().install()
assert isinstance(curl, CurlBinary)                                 # CurlBinary is a unique type you can use in annotations now
print(curl.abspath, curl.version, curl.binprovider, curl.is_valid)  # Path('/opt/homebrew/bin/curl') SemVer('8.4.0') BrewProvider() True
curl.exec(cmd=['--version'])                                        # curl 8.4.0 (x86_64-apple-darwin23.0) libcurl/8.4.0 ...
from pydantic_pkgr import Binary, EnvProvider, PipProvider

# We also provide direct package manager (aka BinProvider) APIs
apt = AptProvider()
apt.install('wget')
print(apt.PATH, apt.get_abspaths('wget'), apt.get_version('wget'))

# even if packages are installed by tools we don't control (e.g. pyinfra/ansible/puppet/etc.)
from pyinfra.operations import apt
apt.packages(name="Install ffmpeg", packages=['ffmpeg'], _sudo=True)

# our Binary API provides a nice type-checkable, validated, serializable handle
ffmpeg = Binary(name='ffmpeg').load()
print(ffmpeg)                       # name=ffmpeg abspath=/usr/bin/ffmpeg version=3.3.0 is_valid=True ...
print(ffmpeg.loaded_abspaths)       # show all the ffmpeg binaries found in $PATH (in case theres more than one available)
print(ffmpeg.model_dump_json())     # ... everything can also be dumped/loaded as json
print(ffmpeg.model_json_schema())   # ... all types provide OpenAPI-ready JSON schemas

Supported Package Managers

So far it supports installing/finding installed/updating/removing packages on Linux/macOS with:

  • apt (Ubuntu/Debian/etc.)
  • brew (macOS/Linux)
  • pip (Linux/macOS/Windows)
  • npm (Linux/macOS/Windows)
  • env (looks for existing version of binary in user's $PATH at runtime)
  • vendor (you can bundle vendored copies of packages you depend on within your source)

Planned: docker, cargo, nix, apk, go get, gem, pkg, and more using ansible/pyinfra...


Usage

pip install pydantic-pkgr

BinProvider

Implementations: EnvProvider, AptProvider, BrewProvider, PipProvider, NpmProvider

This type represents a "provider of binaries", e.g. a package manager like apt/pip/npm, or env (which finds binaries in your $PATH).

BinProviders implement the following interface:

  • .INSTALLER_BIN -> /opt/homebrew/bin/brew provider's pkg manager location
  • .PATH -> PATHStr('/opt/homebrew/bin:/usr/local/bin:...') where provider stores bins
  • get_packages(bin_name: str) -> InstallArgs(['curl', 'libcurl4', '...]) find pkg dependencies for a bin
  • install(bin_name: str) install a bin using binprovider to install needed packages
  • load(bin_name: str) find an existing installed binary
  • load_or_install(bin_name: str) -> Binary find existing / install if needed
  • get_version(bin_name: str) -> SemVer('1.0.0') get currently installed version
  • get_abspath(bin_name: str) -> Path('/absolute/path/to/bin') get installed bin abspath
  • get_abspaths(bin_name: str) -> [Path('/opt/homebrew/bin/curl'), Path('/other/paths/to/curl'), ...] get all matching bins found
  • get_sha256(bin_name: str) -> str get sha256 hash hexdigest of the binary
import platform
from typing import List
from pydantic_pkgr import EnvProvider, PipProvider, AptProvider, BrewProvider

### Example: Finding an existing install of bash using the system $PATH environment
env = EnvProvider()
bash = env.load(bin_name='bash')      # Binary('bash', provider=env)
print(bash.abspath)                   # Path('/opt/homebrew/bin/bash')
print(bash.version)                   # SemVer('5.2.26')
bash.exec(['-c', 'echo hi'])          # hi

### Example: Installing curl using the apt package manager
apt = AptProvider()
curl = apt.install(bin_name='curl')   # Binary('curl', provider=apt)
print(curl.abspath)                   # Path('/usr/bin/curl')
print(curl.version)                   # SemVer('8.4.0')
print(curl.sha256)                    # 9fd780521c97365f94c90724d80a889097ae1eeb2ffce67b87869cb7e79688ec
curl.exec(['--version'])              # curl 7.81.0 (x86_64-pc-linux-gnu) libcurl/7.81.0 ...

### Example: Finding/Installing django with pip (w/ customized binpath resolution behavior)
pip = PipProvider(
    abspath_handler={'*': lambda bin_name, **context: inspect.getfile(bin_name)},  # use python inspect to get path instead of os.which
)
django_bin = pip.load_or_install('django') # Binary('django', provider=pip)
print(django_bin.abspath)             # Path('/usr/lib/python3.10/site-packages/django/__init__.py')
print(django_bin.version)             # SemVer('5.0.2')

Binary

This type represents a single binary dependency aka a package (e.g. wget, curl, ffmpeg, etc.).
It can define one or more BinProviders that it supports, along with overrides to customize the behavior for each.

Binarys implement the following interface:

  • load(), install(), load_or_install() -> Binary
  • binprovider: InstanceOf[BinProvider]
  • abspath: Path
  • abspaths: List[Path]
  • version: SemVer
  • sha256: str
from pydantic_pkgr import BinProvider, Binary, BinProviderName, BinName, ProviderLookupDict, SemVer

### Example: Create a re-usable class defining a binary and its providers
class YtdlpBinary(Binary):
    name: BinName = 'ytdlp'
    description: str = 'YT-DLP (Replacement for YouTube-DL) Media Downloader'

    binproviders_supported: List[BinProvider] = [EnvProvider(), PipProvider(), AptProvider(), BrewProvider()]
    
    # customize installed package names for specific package managers
    provider_overrides: Dict[BinProviderName, ProviderLookupDict] = {
        'pip': {'packages': lambda: ['yt-dlp[default,curl-cffi]']}},
        'apt': {'packages': lambda: ['yt-dlp', 'ffmpeg']}},
        'brew': {'packages': 'some.other.module.get_brew_packages'}},  # also accepts dotted import path to function
    }

ytdlp = YtdlpBinary().load_or_install()
print(ytdlp.binprovider)                  # BrewProvider(...)
print(ytdlp.abspath)                      # Path('/opt/homebrew/bin/yt-dlp')
print(ytdlp.abspaths)                     # [Path('/opt/homebrew/bin/yt-dlp'), Path('/usr/local/bin/yt-dlp')]
print(ytdlp.version)                      # SemVer('2024.4.9')
print(ytdlp.sha256)                       # 46c3518cfa788090c42e379971485f56d007a6ce366dafb0556134ca724d6a36
print(ytdlp.is_valid)                     # True
from pydantic_pkgr import BinProvider, Binary, BinProviderName, BinName, ProviderLookupDict, SemVer

#### Example: Create a binary that uses Podman if available, or Docker otherwise
class DockerBinary(Binary):
    name: BinName = 'docker'

    binproviders_supported: List[BinProvider] = [EnvProvider(), AptProvider()]
    
    provider_overrides: Dict[BinProviderName, ProviderLookupDict] = {
        'env': {
            # example: prefer podman if installed (falling back to docker)
            'abspath': lambda: os.which('podman') or os.which('docker') or os.which('docker-ce'),
        },
        'apt': {
            # example: vary installed package name based on your CPU architecture
            'packages': lambda: {
                'amd64': 'docker',
                'armv7l': 'docker-ce',
                'arm64': 'docker-ce',
            }.get(platform.machine(), 'docker'),
        },
    }

docker = DockerBinary().load_or_install()
print(docker.binprovider)                 # EnvProvider()
print(docker.abspath)                     # Path('/usr/local/bin/podman')
print(docker.abspaths)                    # [Path('/usr/local/bin/podman'), Path('/opt/homebrew/bin/podman')]
print(docker.version)                     # SemVer('6.0.2')
print(docker.is_valid)                    # True

# You can also pass **kwargs to override properties at runtime,
# e.g. if you want to force the abspath to be at a specific path:
custom_docker = DockerBinary(abspath='~/custom/bin/podman').load()
print(custom_docker.name)                 # 'docker'
print(custom_docker.binprovider)          # EnvProvider()
print(custom_docker.abspath)              # Path('/Users/example/custom/bin/podman')
print(custom_docker.version)              # SemVer('5.0.2')
print(custom_docker.is_valid)             # True

SemVer

from pydantic_pkgr import SemVer

### Example: Use the SemVer type directly for parsing & verifying version strings
SemVer.parse('Google Chrome 124.0.6367.208+beta_234. 234.234.123')  # SemVer(124, 0, 6367')
SemVer.parse('2024.04.05)                                           # SemVer(2024, 4, 5)
SemVer.parse('1.9+beta')                                            # SemVer(1, 9, 0)
str(SemVer(1, 9, 0))                                                # '1.9.0'

These types are all meant to be used library-style to make writing your own apps easier.
e.g. you can use it to build things like: playwright install --with-deps)




Django Usage

The pydantic ecosystem helps us get auto-generated, type-checked Django fields & forms that support BinProvider and Binary.

[!TIP] For the full Django experience, we recommend installing these 3 excellent packages:


Django Model Usage: Store BinProvider and Binary entries in your model fields

pip install django-pydantic-field

Fore more info see the django-pydantic-field docs...

Example Django models.py showing how to store Binary and BinProvider instances in DB fields:

from typing import List
from django.db import models
from pydantic import InstanceOf
from pydantic_pkgr import BinProvider, Binary, SemVer
from django_pydantic_field import SchemaField

class InstalledBinary(models.Model):
    name = models.CharField(max_length=63)
    binary: Binary = SchemaField()
    binproviders: List[InstanceOf[BinProvider]] = SchemaField(default=[])
    version: SemVer = SchemaField(default=(0,0,1))

And here's how to save a Binary using the example model:

# find existing curl Binary in $PATH
curl = Binary(name='curl').load()

# save it to the DB using our new model
obj = InstalledBinary(
    name='curl',
    binary=curl,                                  # store Binary/BinProvider/SemVer values directly in fields
    binproviders=[env],                           # no need for manual JSON serialization / schema checking
    min_version=SemVer('6.5.0'),
)
obj.save()                                      

When fetching it back from the DB, the Binary field is auto-deserialized / immediately usable:

obj = InstalledBinary.objects.get(name='curl')    # everything is transparently serialized to/from the DB,
                                                  # and is ready to go immediately after querying:
assert obj.binary.abspath == curl.abspath
print(obj.binary.abspath)                         #   Path('/usr/local/bin/curl')
obj.binary.exec(['--version'])                    #   curl 7.81.0 (x86_64-apple-darwin23.0) libcurl/7.81.0 ...

For a full example see our provided django_example_project/...


Django Admin Usage: Display Binary objects nicely in the Admin UI

Django Admin binaries list viewDjango Admin binaries detail view

pip install pydantic-pkgr django-admin-data-views

For more info see the django-admin-data-views docs...

Then add this to your settings.py:

INSTALLED_APPS = [
    # ...
    'admin_data_views'
    'pydantic_pkgr'
    # ...
]

# point these to a function that gets the list of all binaries / a single binary
PYDANTIC_PKGR_GET_ALL_BINARIES = 'pydantic_pkgr.views.get_all_binaries'
PYDANTIC_PKGR_GET_BINARY = 'pydantic_pkgr.views.get_binary'

ADMIN_DATA_VIEWS = {
    "NAME": "Environment",
    "URLS": [
        {
            "route": "binaries/",
            "view": "pydantic_pkgr.views.binaries_list_view",
            "name": "binaries",
            "items": {
                "route": "<str:key>/",
                "view": "pydantic_pkgr.views.binary_detail_view",
                "name": "binary",
            },
        },
        # Coming soon: binprovider_list_view + binprovider_detail_view ...
    ],
}

For a full example see our provided django_example_project/...

Note: If you override the default site admin, you must register the views manually...

admin.py:

class YourSiteAdmin(admin.AdminSite):
    """Your customized version of admin.AdminSite"""
    ...

custom_admin = YourSiteAdmin() custom_admin.register(get_user_model()) ... from pydantic_pkgr.admin import register_admin_views register_admin_views(custom_admin)

Django Admin Usage: JSONFormWidget for editing BinProvider and Binary data

[!IMPORTANT] This feature is coming soon but is blocked on a few issues being fixed first:

Expand to see more...

Install django-jsonform to get auto-generated Forms for editing BinProvider, Binary, etc. data

pip install django-pydantic-field django-jsonform

For more info see the django-jsonform docs...

admin.py:

from django.contrib import admin
from django_jsonform.widgets import JSONFormWidget
from django_pydantic_field.v2.fields import PydanticSchemaField

class MyModelAdmin(admin.ModelAdmin):
    formfield_overrides = {PydanticSchemaField: {"widget": JSONFormWidget}}

admin.site.register(MyModel, MyModelAdmin)

For a full example see our provided django_example_project/...




Examples

Advanced: Implement your own package manager behavior by subclassing BinProvider

from subprocess import run, PIPE

from pydantic_pkgr import BinProvider, BinProviderName, BinName, SemVer

class CargoProvider(BinProvider):
    name: BinProviderName = 'cargo'
    
    def on_setup_paths(self):
        if '~/.cargo/bin' not in sys.path:
            sys.path.append('~/.cargo/bin')

    def on_install(self, bin_name: BinName, **context):
        packages = self.on_get_packages(bin_name)
        installer_process = run(['cargo', 'install', *packages.split(' ')], capture_output = True, text=True)
        assert installer_process.returncode == 0

    def on_get_packages(self, bin_name: BinName, **context) -> InstallArgs:
        # optionally remap bin_names to strings passed to installer 
        # e.g. 'yt-dlp' -> ['yt-dlp, 'ffmpeg', 'libcffi', 'libaac']
        return [bin_name]

    def on_get_abspath(self, bin_name: BinName, **context) -> Path | None:
        self.on_setup_paths()
        return Path(os.which(bin_name))

    def on_get_version(self, bin_name: BinName, **context) -> SemVer | None:
        self.on_setup_paths()
        return SemVer(run([bin_name, '--version'], stdout=PIPE).stdout.decode())

cargo = CargoProvider()
rg = cargo.install(bin_name='ripgrep')
print(rg.binprovider)                   # CargoProvider()
print(rg.version)                       # SemVer(14, 1, 0)



TODO

  • Implement initial basic support for apt, brew, and pip
  • Provide editability and actions via Django Admin UI using django-pydantic-field and django-jsonform
  • Implement update and remove actions on BinProviders
  • Add preinstall and postinstall hooks for things like adding apt sources and running cleanup scripts
  • Implement more package managers (cargo, gem, go get, ppm, nix, docker, etc.)
  • Add Binary.min_version that affects .is_valid based on whether it meets minimum SemVer threshold

Other Packages We Like

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pydantic_pkgr-0.4.0.tar.gz (36.9 kB view details)

Uploaded Source

Built Distribution

pydantic_pkgr-0.4.0-py3-none-any.whl (39.5 kB view details)

Uploaded Python 3

File details

Details for the file pydantic_pkgr-0.4.0.tar.gz.

File metadata

  • Download URL: pydantic_pkgr-0.4.0.tar.gz
  • Upload date:
  • Size: 36.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: pdm/2.19.1 CPython/3.11.9 Darwin/23.6.0

File hashes

Hashes for pydantic_pkgr-0.4.0.tar.gz
Algorithm Hash digest
SHA256 964e03e4106d13747c918a7b1188723ee1a2b77343a638a111695ae1545f4864
MD5 29b7f0391df7dee7927784a97cebc009
BLAKE2b-256 63ca14b2cd1bee9fbe365476dc59f4aaf3af9f028cb5726f76ae9471976ae101

See more details on using hashes here.

File details

Details for the file pydantic_pkgr-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: pydantic_pkgr-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 39.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: pdm/2.19.1 CPython/3.11.9 Darwin/23.6.0

File hashes

Hashes for pydantic_pkgr-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a13c4cc1c6236ee0951ad0d5da9fe2c1e9ab1466535a30671fc9673415bb4490
MD5 372efaf500c07e497ecee44662e0c694
BLAKE2b-256 e4e4544c264c5935cdf0f786e15889a6343d222dceb2bdeb53aec5a072483f18

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page