├── .github
├── FUNDING.yml
└── workflows
│ ├── jekyll-gh-pages.yml
│ └── tests.yml
├── .gitignore
├── LICENSE
├── README.md
├── abx_pkg
├── __init__.py
├── admin.py
├── apps.py
├── base_types.py
├── binary.py
├── binprovider.py
├── binprovider_ansible.py
├── binprovider_apt.py
├── binprovider_brew.py
├── binprovider_npm.py
├── binprovider_pip.py
├── binprovider_pyinfra.py
├── models.py
├── semver.py
├── settings.py
├── shallowbinary.py
├── tests.py
└── views.py
├── bin
└── publish.sh
├── django_example_project
├── README.md
├── manage.py
└── project
│ ├── __init__.py
│ ├── admin.py
│ ├── asgi.py
│ ├── migrations
│ ├── 0001_initial.py
│ ├── 0002_alter_dependency_options_dependency_min_version.py
│ └── __init__.py
│ ├── models.py
│ ├── settings.py
│ ├── urls.py
│ ├── views.py
│ └── wsgi.py
├── pyproject.toml
├── tests.py
└── uv.lock
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: ["ArchiveBox", "pirate"]
4 | custom: ["https://donate.archivebox.io/"]
5 |
--------------------------------------------------------------------------------
/.github/workflows/jekyll-gh-pages.yml:
--------------------------------------------------------------------------------
1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages
2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ["main"]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
20 | concurrency:
21 | group: "pages"
22 | cancel-in-progress: false
23 |
24 | jobs:
25 | # Build job
26 | build:
27 | runs-on: ubuntu-latest
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 | - name: Setup Pages
32 | uses: actions/configure-pages@v5
33 | - name: Build with Jekyll
34 | uses: actions/jekyll-build-pages@v1
35 | with:
36 | source: ./
37 | destination: ./_site
38 | - name: Upload artifact
39 | uses: actions/upload-pages-artifact@v3
40 |
41 | # Deployment job
42 | deploy:
43 | environment:
44 | name: github-pages
45 | url: ${{ steps.deployment.outputs.page_url }}
46 | runs-on: ubuntu-latest
47 | needs: build
48 | steps:
49 | - name: Deploy to GitHub Pages
50 | id: deployment
51 | uses: actions/deploy-pages@v4
52 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | pull_request:
7 | branches: [ "main" ]
8 | workflow_dispatch:
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | build:
15 | runs-on: ${{ matrix.os }}
16 | strategy:
17 | fail-fast: true
18 | matrix:
19 | python_version: ['3.10', '3.11', '3.12']
20 | os: [ubuntu-latest, macOS-latest] # TODO: windows-latest
21 |
22 | steps:
23 | - uses: actions/checkout@v4
24 |
25 | - name: Install uv
26 | uses: astral-sh/setup-uv@v3
27 | with:
28 | enable-cache: true
29 | cache-dependency-glob: "uv.lock"
30 |
31 | - name: Setup venv and install pip dependencies
32 | run: |
33 | uv venv \
34 | && uv sync \
35 | && uv pip install pip \
36 | && echo "/home/linuxbrew/.linuxbrew/bin" >> "$GITHUB_PATH"
37 |
38 | - name: Run tests
39 | run: |
40 | source .venv/bin/activate \
41 | && python tests.py
42 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pypackages__/
2 | __pycache__/
3 | .mypy_cache/
4 | *.py[cod]
5 | dist/
6 | .pdm-build
7 | .pdm-python
8 |
9 | venv/
10 | .venv/
11 |
12 | .vscode
13 | *.code-workspace
14 | .env
15 | *.log
16 | .DS_Store
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Nick Sweeting
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
abx-pkg
📦 apt
brew
pip
npm
₊₊₊
Simple Python interfaces for package managers + installed binaries.
2 |
3 |
4 | [![PyPI][pypi-badge]][pypi]
5 | [![Python Version][version-badge]][pypi]
6 | [![Django Version][django-badge]][pypi]
7 | [![GitHub][licence-badge]][licence]
8 | [![GitHub Last Commit][repo-badge]][repo]
9 |
10 |
11 |
12 |
13 | **It's an ORM for your package managers, providing a nice python types for packages + installers.**
14 |
15 | **This is a [Python library](https://pypi.org/project/abx-pkg/) for installing & managing packages locally with a variety of package managers.**
16 | It's designed for when `requirements.txt` isn't enough, and you have to detect or install dependencies at runtime. It's great for installing and managing MCP servers and their dependencies at runtime.
17 |
18 |
19 | ```python
20 | pip install abx-pkg
21 |
22 | python
23 | >>> from abx_pkg import Binary, NpmProvider
24 |
25 | >>> curl = Binary('curl').load()
26 | >>> print(curl.abspath, curl.version, curl.exec(cmd=['--version']))
27 | /usr/bin/curl 7.81.0 curl 7.81.0 (x86_64-apple-darwin23.0) libcurl/7.81.0 ...
28 |
29 | >>> NpmProvider().install('puppeteer')
30 | ```
31 |
32 |
33 | > ✨ Built with [`pydantic`](https://pydantic-docs.helpmanual.io/) v2 for strong static typing guarantees and json import/export compatibility
34 | > 📦 Provides consistent cross-platform interfaces for dependency resolution & installation at runtime
35 | > 🌈 Integrates with [`django`](https://docs.djangoproject.com/en/5.0/) >= 4.0, [`django-ninja`](https://django-ninja.dev/), and OpenAPI + [`django-jsonform`](https://django-jsonform.readthedocs.io/) out-of-the-box
36 | > 🦄 Lets you use the builtin `abx-pkg` methods, or [`pyinfra`](https://github.com/pyinfra-dev/pyinfra) / [`ansible`](https://github.com/ansible/ansible) for the install operations
37 |
38 | Built by ArchiveBox to install & auto-update our extractor dependencies at runtime (chrome
, wget
, curl
, etc.) on `macOS`/`Linux`/`Docker`.
39 |
40 |
41 |
42 | > [!WARNING]
43 | > This is `BETA` software, the API is mostly stable but there may be minor changes later on.
44 |
45 |
46 | **Source Code**: [https://github.com/ArchiveBox/abx-pkg/](https://github.com/ArchiveBox/abx-pkg/)
47 |
48 | **Documentation**: [https://github.com/ArchiveBox/abx-pkg/blob/main/README.md](https://github.com/ArchiveBox/abx-pkg/blob/main/README.md)
49 |
50 |
51 |
52 | ```python
53 | from abx_pkg import *
54 |
55 | apt, brew, pip, npm, env = AptProvider(), BrewProvider(), PipProvider(), NpmProvider(), EnvProvider()
56 |
57 | dependencies = [
58 | Binary(name='curl', binproviders=[env, apt, brew]),
59 | Binary(name='wget', binproviders=[env, apt, brew]),
60 | Binary(name='yt-dlp', binproviders=[env, pip, apt, brew]),
61 | Binary(name='playwright', binproviders=[env, pip, npm]),
62 | Binary(name='puppeteer', binproviders=[env, npm]),
63 | ]
64 | for binary in dependencies:
65 | binary = binary.load_or_install()
66 |
67 | print(binary.abspath, binary.version, binary.binprovider, binary.is_valid, binary.sha256)
68 | # Path('/usr/bin/curl') SemVer('7.81.0') AptProvider() True abc134...
69 |
70 | binary.exec(cmd=['--version']) # curl 7.81.0 (x86_64-apple-darwin23.0) libcurl/7.81.0 ...
71 | ```
72 |
73 | ```python
74 | from pydantic import InstanceOf
75 | from abx_pkg import Binary, BinProvider, BrewProvider, EnvProvider
76 |
77 | # you can also define binaries as classes, making them usable for type checking
78 | class CurlBinary(Binary):
79 | name: str = 'curl'
80 | binproviders: list[InstanceOf[BinProvider]] = [BrewProvider(), EnvProvider()]
81 |
82 | curl = CurlBinary().install()
83 | assert isinstance(curl, CurlBinary) # CurlBinary is a unique type you can use in annotations now
84 | print(curl.abspath, curl.version, curl.binprovider, curl.is_valid) # Path('/opt/homebrew/bin/curl') SemVer('8.4.0') BrewProvider() True
85 | curl.exec(cmd=['--version']) # curl 8.4.0 (x86_64-apple-darwin23.0) libcurl/8.4.0 ...
86 | ```
87 |
88 | ```python
89 | from abx_pkg import Binary, EnvProvider, PipProvider
90 |
91 | # We also provide direct package manager (aka BinProvider) APIs
92 | apt = AptProvider()
93 | apt.install('wget')
94 | print(apt.PATH, apt.get_abspaths('wget'), apt.get_version('wget'))
95 |
96 | # even if packages are installed by tools we don't control (e.g. pyinfra/ansible/puppet/etc.)
97 | from pyinfra.operations import apt
98 | apt.packages(name="Install ffmpeg", packages=['ffmpeg'], _sudo=True)
99 |
100 | # our Binary API provides a nice type-checkable, validated, serializable handle
101 | ffmpeg = Binary(name='ffmpeg').load()
102 | print(ffmpeg) # name=ffmpeg abspath=/usr/bin/ffmpeg version=3.3.0 is_valid=True binprovider=apt ...
103 | print(ffmpeg.abspaths) # show all the ffmpeg binaries found in $PATH (in case theres more than one available)
104 | print(ffmpeg.model_dump()) # ... everything can also be dumped/loaded as json-ready dict
105 | print(ffmpeg.model_json_schema()) # ... OpenAPI-ready JSON schema showing all available fields
106 | ```
107 |
108 | ### Supported Package Managers
109 |
110 | **So far it supports `installing`/`finding installed`/~~`updating`/`removing`~~ packages on `Linux`/`macOS` with:**
111 |
112 | - `apt` (Ubuntu/Debian/etc.)
113 | - `brew` (macOS/Linux)
114 | - `pip` (Linux/macOS/Windows)
115 | - `npm` (Linux/macOS/Windows)
116 | - `env` (looks for existing version of binary in user's `$PATH` at runtime)
117 | - `vendor` (you can bundle vendored copies of packages you depend on within your source)
118 |
119 | *Planned:* `docker`, `cargo`, `nix`, `apk`, `go get`, `gem`, `pkg`, *and more using `ansible`/[`pyinfra`](https://github.com/pyinfra-dev/pyinfra)...*
120 |
121 | ---
122 |
123 |
124 | ## Usage
125 |
126 | ```bash
127 | pip install abx-pkg
128 | ```
129 |
130 | ### [`BinProvider`](https://github.com/ArchiveBox/abx-pkg/blob/main/abx_pkg/binprovider.py#:~:text=class%20BinProvider)
131 |
132 | **Implementations: `EnvProvider`, `AptProvider`, `BrewProvider`, `PipProvider`, `NpmProvider`**
133 |
134 | This type represents a "provider of binaries", e.g. a package manager like `apt`/`pip`/`npm`, or `env` (which finds binaries in your `$PATH`).
135 |
136 | `BinProvider`s implement the following interface:
137 | * `.INSTALLER_BIN -> /opt/homebrew/bin/brew` provider's pkg manager location
138 | * `.PATH -> PATHStr('/opt/homebrew/bin:/usr/local/bin:...')` where provider stores bins
139 | * `get_packages(bin_name: str) -> InstallArgs(['curl', 'libcurl4', '...])` find pkg dependencies for a bin
140 | - `install(bin_name: str)` install a bin using binprovider to install needed packages
141 | - `load(bin_name: str)` find an existing installed binary
142 | - `load_or_install(bin_name: str)` `->` `Binary` find existing / install if needed
143 | - `get_version(bin_name: str) -> SemVer('1.0.0')` get currently installed version
144 | - `get_abspath(bin_name: str) -> Path('/absolute/path/to/bin')` get installed bin abspath
145 | * `get_abspaths(bin_name: str) -> [Path('/opt/homebrew/bin/curl'), Path('/other/paths/to/curl'), ...]` get all matching bins found
146 | * `get_sha256(bin_name: str) -> str` get sha256 hash hexdigest of the binary
147 |
148 |
149 | ```python
150 | import platform
151 | from typing import List
152 | from abx_pkg import EnvProvider, PipProvider, AptProvider, BrewProvider
153 |
154 | ### Example: Finding an existing install of bash using the system $PATH environment
155 | env = EnvProvider()
156 | bash = env.load(bin_name='bash') # Binary('bash', provider=env)
157 | print(bash.abspath) # Path('/opt/homebrew/bin/bash')
158 | print(bash.version) # SemVer('5.2.26')
159 | bash.exec(['-c', 'echo hi']) # hi
160 |
161 | ### Example: Installing curl using the apt package manager
162 | apt = AptProvider()
163 | curl = apt.install(bin_name='curl') # Binary('curl', provider=apt)
164 | print(curl.abspath) # Path('/usr/bin/curl')
165 | print(curl.version) # SemVer('8.4.0')
166 | print(curl.sha256) # 9fd780521c97365f94c90724d80a889097ae1eeb2ffce67b87869cb7e79688ec
167 | curl.exec(['--version']) # curl 7.81.0 (x86_64-pc-linux-gnu) libcurl/7.81.0 ...
168 |
169 | ### Example: Finding/Installing django with pip (w/ customized binpath resolution behavior)
170 | pip = PipProvider(
171 | abspath_handler={'*': lambda self, bin_name, **context: inspect.getfile(bin_name)}, # use python inspect to get path instead of os.which
172 | )
173 | django_bin = pip.load_or_install('django') # Binary('django', provider=pip)
174 | print(django_bin.abspath) # Path('/usr/lib/python3.10/site-packages/django/__init__.py')
175 | print(django_bin.version) # SemVer('5.0.2')
176 | ```
177 |
178 | ### [`Binary`](https://github.com/ArchiveBox/abx-pkg/blob/main/abx_pkg/binary.py#:~:text=class%20Binary)
179 |
180 | This type represents a single binary dependency aka a package (e.g. `wget`, `curl`, `ffmpeg`, etc.).
181 | It can define one or more `BinProvider`s that it supports, along with overrides to customize the behavior for each.
182 |
183 | `Binary`s implement the following interface:
184 | - `load()`, `install()`, `load_or_install()` `->` `Binary`
185 | - `binprovider: InstanceOf[BinProvider]`
186 | - `abspath: Path`
187 | - `abspaths: list[Path]`
188 | - `version: SemVer`
189 | - `sha256: str`
190 |
191 | ```python
192 | from abx_pkg import BinProvider, Binary, BinProviderName, BinName, ProviderLookupDict, SemVer
193 |
194 | class CustomBrewProvider(BrewProvider):
195 | name: str = 'custom_brew'
196 |
197 | def get_macos_packages(self, bin_name: str, **context) -> list[str]:
198 | extra_packages_lookup_table = json.load(Path('macos_packages.json'))
199 | return extra_packages_lookup_table.get(platform.machine(), [bin_name])
200 |
201 |
202 | ### Example: Create a re-usable class defining a binary and its providers
203 | class YtdlpBinary(Binary):
204 | name: BinName = 'ytdlp'
205 | description: str = 'YT-DLP (Replacement for YouTube-DL) Media Downloader'
206 |
207 | binproviders_supported: list[BinProvider] = [EnvProvider(), PipProvider(), AptProvider(), CustomBrewProvider()]
208 |
209 | # customize installed package names for specific package managers
210 | provider_overrides: dict[BinProviderName, ProviderLookupDict] = {
211 | 'pip': {'packages': ['yt-dlp[default,curl-cffi]']}, # can use literal values (packages -> list[str], version -> SemVer, abspath -> Path, install -> str log)
212 | 'apt': {'packages': lambda: ['yt-dlp', 'ffmpeg']}, # also accepts any pure Callable that returns a list of packages
213 | 'brew': {'packages': 'self.get_macos_packages'}, # also accepts string reference to function on self (where self is the BinProvider)
214 | }
215 |
216 |
217 | ytdlp = YtdlpBinary().load_or_install()
218 | print(ytdlp.binprovider) # BrewProvider(...)
219 | print(ytdlp.abspath) # Path('/opt/homebrew/bin/yt-dlp')
220 | print(ytdlp.abspaths) # [Path('/opt/homebrew/bin/yt-dlp'), Path('/usr/local/bin/yt-dlp')]
221 | print(ytdlp.version) # SemVer('2024.4.9')
222 | print(ytdlp.sha256) # 46c3518cfa788090c42e379971485f56d007a6ce366dafb0556134ca724d6a36
223 | print(ytdlp.is_valid) # True
224 | ```
225 |
226 | ```python
227 | from abx_pkg import BinProvider, Binary, BinProviderName, BinName, ProviderLookupDict, SemVer
228 |
229 | #### Example: Create a binary that uses Podman if available, or Docker otherwise
230 | class DockerBinary(Binary):
231 | name: BinName = 'docker'
232 |
233 | binproviders_supported: list[BinProvider] = [EnvProvider(), AptProvider()]
234 |
235 | provider_overrides: dict[BinProviderName, ProviderLookupDict] = {
236 | 'env': {
237 | # example: prefer podman if installed (falling back to docker)
238 | 'abspath': lambda: os.which('podman') or os.which('docker') or os.which('docker-ce'),
239 | },
240 | 'apt': {
241 | # example: vary installed package name based on your CPU architecture
242 | 'packages': {
243 | 'amd64': ['docker'],
244 | 'armv7l': ['docker-ce'],
245 | 'arm64': ['docker-ce'],
246 | }.get(platform.machine(), 'docker'),
247 | },
248 | }
249 |
250 | docker = DockerBinary().load_or_install()
251 | print(docker.binprovider) # EnvProvider()
252 | print(docker.abspath) # Path('/usr/local/bin/podman')
253 | print(docker.abspaths) # [Path('/usr/local/bin/podman'), Path('/opt/homebrew/bin/podman')]
254 | print(docker.version) # SemVer('6.0.2')
255 | print(docker.is_valid) # True
256 |
257 | # You can also pass **kwargs to override properties at runtime,
258 | # e.g. if you want to force the abspath to be at a specific path:
259 | custom_docker = DockerBinary(abspath='~/custom/bin/podman').load()
260 | print(custom_docker.name) # 'docker'
261 | print(custom_docker.binprovider) # EnvProvider()
262 | print(custom_docker.abspath) # Path('/Users/example/custom/bin/podman')
263 | print(custom_docker.version) # SemVer('5.0.2')
264 | print(custom_docker.is_valid) # True
265 | ```
266 |
267 | ### [`SemVer`](https://github.com/ArchiveBox/abx-pkg/blob/main/abx_pkg/semver.py#:~:text=class%20SemVer)
268 |
269 | ```python
270 | from abx_pkg import SemVer
271 |
272 | ### Example: Use the SemVer type directly for parsing & verifying version strings
273 | SemVer.parse('Google Chrome 124.0.6367.208+beta_234. 234.234.123') # SemVer(124, 0, 6367')
274 | SemVer.parse('2024.04.05) # SemVer(2024, 4, 5)
275 | SemVer.parse('1.9+beta') # SemVer(1, 9, 0)
276 | str(SemVer(1, 9, 0)) # '1.9.0'
277 | ```
278 |
279 |
280 | > These types are all meant to be used library-style to make writing your own apps easier.
281 | > e.g. you can use it to build things like: [`playwright install --with-deps`](https://playwright.dev/docs/browsers#install-system-dependencies))
282 |
283 |
284 |
285 |
286 | ---
287 | ---
288 |
289 |
290 |
291 |
292 |
293 | ## Django Usage
294 |
295 | With a few more packages, you get type-checked Django fields & forms that support `BinProvider` and `Binary`.
296 |
297 | > [!TIP]
298 | > For the full Django experience, we recommend installing these 3 excellent packages:
299 | > - [`django-admin-data-views`](https://github.com/MrThearMan/django-admin-data-views)
300 | > - [`django-pydantic-field`](https://github.com/surenkov/django-pydantic-field)
301 | > - [`django-jsonform`](https://django-jsonform.readthedocs.io/)
302 | > `pip install abx-pkg django-admin-data-views django-pydantic-field django-jsonform`
303 |
304 |
305 |
306 | ### Django Model Usage: Store `BinProvider` and `Binary` entries in your model fields
307 |
308 | ```bash
309 | pip install django-pydantic-field
310 | ```
311 |
312 | *Fore more info see the [`django-pydantic-field`](https://github.com/surenkov/django-pydantic-field) docs...*
313 |
314 | Example Django `models.py` showing how to store `Binary` and `BinProvider` instances in DB fields:
315 | ```python
316 | from typing import List
317 | from django.db import models
318 | from pydantic import InstanceOf
319 | from abx_pkg import BinProvider, Binary, SemVer
320 | from django_pydantic_field import SchemaField
321 |
322 | class InstalledBinary(models.Model):
323 | name = models.CharField(max_length=63)
324 | binary: Binary = SchemaField()
325 | binproviders: list[InstanceOf[BinProvider]] = SchemaField(default=[])
326 | version: SemVer = SchemaField(default=(0,0,1))
327 | ```
328 |
329 | And here's how to save a `Binary` using the example model:
330 | ```python
331 | # find existing curl Binary in $PATH
332 | curl = Binary(name='curl').load()
333 |
334 | # save it to the DB using our new model
335 | obj = InstalledBinary(
336 | name='curl',
337 | binary=curl, # store Binary/BinProvider/SemVer values directly in fields
338 | binproviders=[env], # no need for manual JSON serialization / schema checking
339 | min_version=SemVer('6.5.0'),
340 | )
341 | obj.save()
342 | ```
343 |
344 | When fetching it back from the DB, the `Binary` field is auto-deserialized / immediately usable:
345 | ```
346 | obj = InstalledBinary.objects.get(name='curl') # everything is transparently serialized to/from the DB,
347 | # and is ready to go immediately after querying:
348 | assert obj.binary.abspath == curl.abspath
349 | print(obj.binary.abspath) # Path('/usr/local/bin/curl')
350 | obj.binary.exec(['--version']) # curl 7.81.0 (x86_64-apple-darwin23.0) libcurl/7.81.0 ...
351 | ```
352 | *For a full example see our provided [`django_example_project/`](https://github.com/ArchiveBox/abx-pkg/tree/main/django_example_project)...*
353 |
354 |
355 |
356 | ### Django Admin Usage: Display `Binary` objects nicely in the Admin UI
357 |
358 | 
359 |
360 | ```bash
361 | pip install abx-pkg django-admin-data-views
362 | ```
363 | *For more info see the [`django-admin-data-views`](https://github.com/MrThearMan/django-admin-data-views) docs...*
364 |
365 | Then add this to your `settings.py`:
366 | ```python
367 | INSTALLED_APPS = [
368 | # ...
369 | 'admin_data_views'
370 | 'abx_pkg'
371 | # ...
372 | ]
373 |
374 | # point these to a function that gets the list of all binaries / a single binary
375 | ABX_PKG_GET_ALL_BINARIES = 'abx_pkg.views.get_all_binaries'
376 | ABX_PKG_GET_BINARY = 'abx_pkg.views.get_binary'
377 |
378 | ADMIN_DATA_VIEWS = {
379 | "NAME": "Environment",
380 | "URLS": [
381 | {
382 | "route": "binaries/",
383 | "view": "abx_pkg.views.binaries_list_view",
384 | "name": "binaries",
385 | "items": {
386 | "route": "/",
387 | "view": "abx_pkg.views.binary_detail_view",
388 | "name": "binary",
389 | },
390 | },
391 | # Coming soon: binprovider_list_view + binprovider_detail_view ...
392 | ],
393 | }
394 | ```
395 | *For a full example see our provided [`django_example_project/`](https://github.com/ArchiveBox/abx-pkg/tree/main/django_example_project)...*
396 |
397 |
398 | Note: If you override the default site admin, you must register the views manually...
399 |
400 | admin.py
:
401 |
402 |
403 | class YourSiteAdmin(admin.AdminSite):
404 | """Your customized version of admin.AdminSite"""
405 | ...
406 |
407 | custom_admin = YourSiteAdmin()
408 | custom_admin.register(get_user_model())
409 | ...
410 | from abx_pkg.admin import register_admin_views
411 | register_admin_views(custom_admin)
412 |
413 |
414 |
415 |
416 |
417 | ### ~~Django Admin Usage: JSONFormWidget for editing `BinProvider` and `Binary` data~~
418 |
419 | Expand to see more...
420 |
421 |
422 |
423 | > [!IMPORTANT]
424 | > This feature is coming soon but is blocked on a few issues being fixed first:
425 | > - https://github.com/surenkov/django-pydantic-field/issues/64
426 | > - https://github.com/surenkov/django-pydantic-field/issues/65
427 | > - https://github.com/surenkov/django-pydantic-field/issues/66
428 |
429 | ~~Install `django-jsonform` to get auto-generated Forms for editing BinProvider, Binary, etc. data~~
430 | ```bash
431 | pip install django-pydantic-field django-jsonform
432 | ```
433 | *For more info see the [`django-jsonform`](https://django-jsonform.readthedocs.io/) docs...*
434 |
435 | `admin.py`:
436 | ```python
437 | from django.contrib import admin
438 | from django_jsonform.widgets import JSONFormWidget
439 | from django_pydantic_field.v2.fields import PydanticSchemaField
440 |
441 | class MyModelAdmin(admin.ModelAdmin):
442 | formfield_overrides = {PydanticSchemaField: {"widget": JSONFormWidget}}
443 |
444 | admin.site.register(MyModel, MyModelAdmin)
445 | ```
446 |
447 |
448 |
449 | *For a full example see our provided [`django_example_project/`](https://github.com/ArchiveBox/abx-pkg/tree/main/django_example_project)...*
450 |
451 |
452 |
453 | ---
454 |
455 |
456 |
457 |
458 | ## Examples
459 |
460 | ### Advanced: Implement your own package manager behavior by subclassing BinProvider
461 |
462 | ```python
463 | from subprocess import run, PIPE
464 |
465 | from abx_pkg import BinProvider, BinProviderName, BinName, SemVer
466 |
467 | class CargoProvider(BinProvider):
468 | name: BinProviderName = 'cargo'
469 |
470 | def on_setup_paths(self):
471 | if '~/.cargo/bin' not in sys.path:
472 | sys.path.append('~/.cargo/bin')
473 |
474 | def on_install(self, bin_name: BinName, **context):
475 | packages = self.on_get_packages(bin_name)
476 | installer_process = run(['cargo', 'install', *packages.split(' ')], capture_output = True, text=True)
477 | assert installer_process.returncode == 0
478 |
479 | def on_get_packages(self, bin_name: BinName, **context) -> InstallArgs:
480 | # optionally remap bin_names to strings passed to installer
481 | # e.g. 'yt-dlp' -> ['yt-dlp, 'ffmpeg', 'libcffi', 'libaac']
482 | return [bin_name]
483 |
484 | def on_get_abspath(self, bin_name: BinName, **context) -> Path | None:
485 | self.on_setup_paths()
486 | return Path(os.which(bin_name))
487 |
488 | def on_get_version(self, bin_name: BinName, **context) -> SemVer | None:
489 | self.on_setup_paths()
490 | return SemVer(run([bin_name, '--version'], stdout=PIPE).stdout.decode())
491 |
492 | cargo = CargoProvider()
493 | rg = cargo.install(bin_name='ripgrep')
494 | print(rg.binprovider) # CargoProvider()
495 | print(rg.version) # SemVer(14, 1, 0)
496 | ```
497 |
498 |
499 |
500 |
501 | ---
502 |
503 |
504 |
505 | *Note:* this package used to be called `pydantic-pkgr`, it was renamed to `abx-pkg` on 2024-11-12.
506 |
507 | ### TODO
508 |
509 | - [x] Implement initial basic support for `apt`, `brew`, and `pip`
510 | - [x] Provide editability and actions via Django Admin UI using [`django-pydantic-field`](https://github.com/surenkov/django-pydantic-field) and [`django-jsonform`](https://django-jsonform.readthedocs.io/en/latest/)
511 | - [ ] Implement `update` and `remove` actions on BinProviders
512 | - [ ] Add `preinstall` and `postinstall` hooks for things like adding `apt` sources and running cleanup scripts
513 | - [ ] Implement more package managers (`cargo`, `gem`, `go get`, `ppm`, `nix`, `docker`, etc.)
514 | - [ ] Add `Binary.min_version` that affects `.is_valid` based on whether it meets minimum `SemVer` threshold
515 |
516 |
517 | ### Other Packages We Like
518 |
519 | - https://github.com/MrThearMan/django-signal-webhooks
520 | - https://github.com/MrThearMan/django-admin-data-views
521 | - https://github.com/lazybird/django-solo
522 | - https://github.com/joshourisman/django-pydantic-settings
523 | - https://github.com/surenkov/django-pydantic-field
524 | - https://github.com/jordaneremieff/djantic
525 |
526 | [coverage-badge]: https://coveralls.io/repos/github/ArchiveBox/abx-pkg/badge.svg?branch=main
527 | [status-badge]: https://img.shields.io/github/actions/workflow/status/ArchiveBox/abx-pkg/test.yml?branch=main
528 | [pypi-badge]: https://img.shields.io/pypi/v/abx-pkg?v=1
529 | [licence-badge]: https://img.shields.io/github/license/ArchiveBox/abx-pkg?v=1
530 | [repo-badge]: https://img.shields.io/github/last-commit/ArchiveBox/abx-pkg?v=1
531 | [issues-badge]: https://img.shields.io/github/issues-raw/ArchiveBox/abx-pkg?v=1
532 | [version-badge]: https://img.shields.io/pypi/pyversions/abx-pkg?v=1
533 | [downloads-badge]: https://img.shields.io/pypi/dm/abx-pkg?v=1
534 | [django-badge]: https://img.shields.io/pypi/djversions/abx-pkg?v=1
535 |
536 | [coverage]: https://coveralls.io/github/ArchiveBox/abx-pkg?branch=main
537 | [status]: https://github.com/ArchiveBox/abx-pkg/actions/workflows/test.yml
538 | [pypi]: https://pypi.org/project/abx-pkg
539 | [licence]: https://github.com/ArchiveBox/abx-pkg/blob/main/LICENSE
540 | [repo]: https://github.com/ArchiveBox/abx-pkg/commits/main
541 | [issues]: https://github.com/ArchiveBox/abx-pkg/issues
542 |
--------------------------------------------------------------------------------
/abx_pkg/__init__.py:
--------------------------------------------------------------------------------
1 | __package__ = "abx_pkg"
2 |
3 | from .base_types import (
4 | BinName,
5 | InstallArgs,
6 | PATHStr,
7 | HostBinPath,
8 | HostExistsPath,
9 | BinDirPath,
10 | BinProviderName,
11 | bin_name,
12 | bin_abspath,
13 | bin_abspaths,
14 | func_takes_args_or_kwargs,
15 | )
16 | from .semver import SemVer, bin_version
17 | from .shallowbinary import ShallowBinary
18 | from .binprovider import (
19 | BinProvider,
20 | EnvProvider,
21 | OPERATING_SYSTEM,
22 | DEFAULT_PATH,
23 | DEFAULT_ENV_PATH,
24 | PYTHON_BIN_DIR,
25 | BinProviderOverrides,
26 | BinaryOverrides,
27 | ProviderFuncReturnValue,
28 | HandlerType,
29 | HandlerValue,
30 | HandlerDict,
31 | HandlerReturnValue,
32 | )
33 | from .binary import Binary
34 |
35 | from .binprovider_apt import AptProvider
36 | from .binprovider_brew import BrewProvider
37 | from .binprovider_pip import PipProvider
38 | from .binprovider_npm import NpmProvider
39 | from .binprovider_ansible import AnsibleProvider
40 | from .binprovider_pyinfra import PyinfraProvider
41 |
42 | ALL_PROVIDERS = [
43 | EnvProvider,
44 | AptProvider,
45 | BrewProvider,
46 | PipProvider,
47 | NpmProvider,
48 | AnsibleProvider,
49 | PyinfraProvider,
50 | ]
51 | ALL_PROVIDER_NAMES = [provider.__fields__['name'].default for provider in ALL_PROVIDERS]
52 | ALL_PROVIDER_CLASSES = [provider.__class__.__name__ for provider in ALL_PROVIDERS]
53 |
54 |
55 | __all__ = [
56 | # Main types
57 | "BinProvider",
58 | "Binary",
59 | "SemVer",
60 | "ShallowBinary",
61 |
62 | # Helper Types
63 | "BinName",
64 | "InstallArgs",
65 | "PATHStr",
66 | "BinDirPath",
67 | "HostBinPath",
68 | "HostExistsPath",
69 | "BinProviderName",
70 |
71 | # Override types
72 | "BinProviderOverrides",
73 | "BinaryOverrides",
74 | "ProviderFuncReturnValue",
75 | "HandlerType",
76 | "HandlerValue",
77 | "HandlerDict",
78 | "HandlerReturnValue",
79 |
80 | # Validator Functions
81 | "bin_version",
82 | "bin_name",
83 | "bin_abspath",
84 | "bin_abspaths",
85 | "func_takes_args_or_kwargs",
86 |
87 | # Globals
88 | "OPERATING_SYSTEM",
89 | "DEFAULT_PATH",
90 | "DEFAULT_ENV_PATH",
91 | "PYTHON_BIN_DIR",
92 |
93 | # BinProviders
94 | *ALL_PROVIDER_CLASSES,
95 | ]
96 |
--------------------------------------------------------------------------------
/abx_pkg/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 |
4 |
5 |
6 | def register_admin_views(admin_site: admin.AdminSite):
7 | """register the django-admin-data-views defined in settings.ADMIN_DATA_VIEWS"""
8 |
9 | from admin_data_views.admin import get_app_list, admin_data_index_view, get_admin_data_urls, get_urls
10 |
11 | CustomAdminCls = admin_site.__class__
12 |
13 | admin_site.get_app_list = get_app_list.__get__(admin_site, CustomAdminCls)
14 | admin_site.admin_data_index_view = admin_data_index_view.__get__(admin_site, CustomAdminCls)
15 | admin_site.get_admin_data_urls = get_admin_data_urls.__get__(admin_site, CustomAdminCls)
16 | admin_site.get_urls = get_urls(admin_site.get_urls).__get__(admin_site, CustomAdminCls)
17 |
18 | return admin_site
19 |
20 |
21 | register_admin_views(admin.site)
22 |
23 | # if you've implemented a custom admin site, you should call this funciton on your site
24 |
25 | # class YourSiteAdmin(admin.AdminSite):
26 | # ...
27 | #
28 | # custom_site = YourSiteAdmin()
29 | #
30 | # register_admin_views(custom_site)
31 |
--------------------------------------------------------------------------------
/abx_pkg/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class AbxPkgConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'abx_pkg'
7 |
--------------------------------------------------------------------------------
/abx_pkg/base_types.py:
--------------------------------------------------------------------------------
1 | __package__ = "abx_pkg"
2 |
3 | import os
4 | import shutil
5 |
6 | from pathlib import Path
7 | from typing import List, Tuple, Callable, Any, Annotated
8 |
9 | from pydantic import TypeAdapter, AfterValidator, BeforeValidator, ValidationError
10 |
11 | def validate_binprovider_name(name: str) -> str:
12 | assert 1 < len(name) < 16, 'BinProvider names must be between 1 and 16 characters long'
13 | assert name.replace('_', '').isalnum(), 'BinProvider names can only contain a-Z0-9 and underscores'
14 | assert name[0].isalpha(), 'BinProvider names must start with a letter'
15 | return name
16 |
17 | BinProviderName = Annotated[str, AfterValidator(validate_binprovider_name)]
18 | # in practice this is essentially BinProviderName: Literal['env', 'pip', 'apt', 'brew', 'npm', 'vendor']
19 | # but because users can create their own BinProviders we cant restrict it to a preset list of literal names
20 |
21 |
22 |
23 | def validate_bin_dir(path: Path) -> Path:
24 | path = path.expanduser().absolute()
25 | assert path.resolve()
26 | assert os.path.isdir(path) and os.access(path, os.R_OK), f'path entries to add to $PATH must be absolute paths to directories {dir}'
27 | return path
28 |
29 | BinDirPath = Annotated[Path, AfterValidator(validate_bin_dir)]
30 |
31 | def validate_PATH(PATH: str | List[str]) -> str:
32 | paths = PATH.split(':') if isinstance(PATH, str) else list(PATH)
33 | assert all(Path(bin_dir) for bin_dir in paths)
34 | return ':'.join(paths).strip(':')
35 |
36 | PATHStr = Annotated[str, BeforeValidator(validate_PATH)]
37 |
38 | def func_takes_args_or_kwargs(lambda_func: Callable[..., Any]) -> bool:
39 | """returns True if a lambda func takes args/kwargs of any kind, otherwise false if it's pure/argless"""
40 | code = lambda_func.__code__
41 | has_args = code.co_argcount > 0
42 | has_varargs = code.co_flags & 0x04 != 0
43 | has_varkw = code.co_flags & 0x08 != 0
44 | return has_args or has_varargs or has_varkw
45 |
46 |
47 | # @validate_call
48 | def bin_name(bin_path_or_name: str | Path) -> str:
49 | """
50 | - wget -> wget
51 | - /usr/bin/wget -> wget
52 | - ~/bin/wget -> wget
53 | - ~/.local/bin/wget -> wget
54 | - @postlight/parser -> @postlight/parser
55 | - @postlight/parser@2.2.3 -> @postlight/parser
56 | - yt-dlp==2024.05.09 -> yt-dlp
57 | - postlight/parser^2.2.3 -> postlight/parser
58 | - @postlight/parser@^2.2.3 -> @postlight/parser
59 | """
60 | str_bin_name = str(bin_path_or_name).split('^', 1)[0].split('=', 1)[0].split('>', 1)[0].split('<', 1)[0]
61 | if str_bin_name.startswith('@'):
62 | # @postlight/parser@^2.2.3 -> @postlight/parser
63 | str_bin_name = '@' + str_bin_name[1:].split('@', 1)[0]
64 | else:
65 | str_bin_name = str_bin_name.split('@', 1)[0]
66 |
67 | assert len(str_bin_name) > 0, 'Binary names must be non-empty'
68 | name = Path(str_bin_name).name if str_bin_name[0] in ('.', '/', '~') else str_bin_name
69 | assert 1 <= len(name) < 64, 'Binary names must be between 1 and 63 characters long'
70 | assert name.replace('-', '').replace('_', '').replace('.', '').replace(' ', '').replace('@', '').replace('/', '').isalnum(), (
71 | f'Binary name can only contain a-Z0-9-_.@/ and spaces: {name}')
72 | assert name.replace('@', '')[0].isalpha(), 'Binary names must start with a letter or @'
73 | # print('PARSING BIN NAME', bin_path_or_name, '->', name)
74 | return name
75 |
76 | BinName = Annotated[str, AfterValidator(bin_name)]
77 |
78 | # @validate_call
79 | def path_is_file(path: Path | str) -> Path:
80 | path = Path(path) if isinstance(path, str) else path
81 | assert os.path.isfile(path) and os.access(path, os.R_OK), f'Path is not a file or we dont have permission to read it: {path}'
82 | return path
83 |
84 | HostExistsPath = Annotated[Path, AfterValidator(path_is_file)]
85 |
86 | # @validate_call
87 | def path_is_executable(path: HostExistsPath) -> HostExistsPath:
88 | assert os.path.isfile(path) and os.access(path, os.X_OK), f'Path is not executable (fix by running chmod +x {path})'
89 | return path
90 |
91 | # @validate_call
92 | def path_is_script(path: HostExistsPath) -> HostExistsPath:
93 | SCRIPT_EXTENSIONS = ('.py', '.js', '.sh')
94 | assert path.suffix.lower() in SCRIPT_EXTENSIONS, 'Path is not a script (does not end in {})'.format(', '.join(SCRIPT_EXTENSIONS))
95 | return path
96 |
97 | HostExecutablePath = Annotated[HostExistsPath, AfterValidator(path_is_executable)]
98 |
99 | # @validate_call
100 | def path_is_abspath(path: Path) -> Path:
101 | path = path.expanduser().absolute() # resolve ~/ -> /home/ HostBinPath | None:
112 | assert bin_path_or_name
113 | if PATH is None:
114 | PATH = os.environ.get('PATH', '/bin')
115 | if PATH:
116 | PATH = str(PATH)
117 | else:
118 | return None
119 |
120 | if str(bin_path_or_name).startswith('/'):
121 | # already a path, get its absolute form
122 | abspath = Path(bin_path_or_name).expanduser().absolute()
123 | else:
124 | # not a path yet, get path using shutil.which
125 | binpath = shutil.which(bin_path_or_name, mode=os.X_OK, path=PATH)
126 | # print(bin_path_or_name, PATH.split(':'), binpath, 'GOPINGNGN')
127 | if not binpath:
128 | # some bins dont show up with shutil.which (e.g. django-admin.py)
129 | for path in PATH.split(':'):
130 | bin_dir = Path(path)
131 | # print('BIN_DIR', bin_dir, bin_dir.is_dir())
132 | if not (os.path.isdir(bin_dir) and os.access(bin_dir, os.R_OK)):
133 | # raise Exception(f'Found invalid dir in $PATH: {bin_dir}')
134 | continue
135 | bin_file = bin_dir / bin_path_or_name
136 | # print(bin_file, path, bin_file.exists(), bin_file.is_file(), bin_file.is_symlink())
137 | if os.path.isfile(bin_file) and os.access(bin_file, os.R_OK):
138 | return bin_file
139 |
140 | return None
141 | # print(binpath, PATH)
142 | if str(Path(binpath).parent) not in PATH:
143 | # print('WARNING, found bin but not in PATH', binpath, PATH)
144 | # found bin but it was outside our search $PATH
145 | return None
146 | abspath = Path(binpath).expanduser().absolute()
147 |
148 | try:
149 | return TypeAdapter(HostBinPath).validate_python(abspath)
150 | except ValidationError:
151 | return None
152 |
153 | # @validate_call
154 | def bin_abspaths(bin_path_or_name: BinName | Path, PATH: PATHStr | None=None) -> List[HostBinPath]:
155 | assert bin_path_or_name
156 |
157 | PATH = PATH or os.environ.get('PATH', '/bin')
158 | abspaths = []
159 |
160 | if str(bin_path_or_name).startswith('/'):
161 | # already a path, get its absolute form
162 | abspaths.append(Path(bin_path_or_name).expanduser().absolute())
163 | else:
164 | # not a path yet, get path using shutil.which
165 | for path in PATH.split(':'):
166 | binpath = shutil.which(bin_path_or_name, mode=os.X_OK, path=path)
167 | if binpath and str(Path(binpath).parent) in PATH:
168 | abspaths.append(binpath)
169 |
170 | try:
171 | return TypeAdapter(List[HostBinPath]).validate_python(abspaths)
172 | except ValidationError:
173 | return []
174 |
175 |
176 |
177 |
178 | ################## Types ##############################################
179 |
180 | UNKNOWN_SHA256 = 'unknown'
181 |
182 | def is_valid_sha256(sha256: str) -> str:
183 | if sha256 == UNKNOWN_SHA256:
184 | return sha256
185 | assert len(sha256) == 64
186 | assert sha256.isalnum()
187 | return sha256
188 |
189 | Sha256 = Annotated[str, AfterValidator(is_valid_sha256)]
190 |
191 | def is_valid_install_args(install_args: List[str] | Tuple[str, ...] | str) -> Tuple[str, ...]:
192 | """Make sure a string is a valid install string for a package manager, e.g. ['yt-dlp', 'ffmpeg']"""
193 | if isinstance(install_args, str):
194 | install_args = [install_args]
195 | assert install_args
196 | assert all(len(arg) for arg in install_args)
197 | return tuple(install_args)
198 |
199 | def is_name_of_method_on_self(method_name: str) -> str:
200 | assert method_name.startswith('self.') and method_name.replace('.', '').replace('_', '').isalnum()
201 | return method_name
202 |
203 | InstallArgs = Annotated[Tuple[str, ...] | List[str], AfterValidator(is_valid_install_args)]
204 |
205 | SelfMethodName = Annotated[str, AfterValidator(is_name_of_method_on_self)]
206 |
207 |
208 |
--------------------------------------------------------------------------------
/abx_pkg/binary.py:
--------------------------------------------------------------------------------
1 | __package__ = 'abx_pkg'
2 |
3 | from typing import Any, Optional, Dict, List
4 | from typing_extensions import Self
5 |
6 | from pydantic import Field, model_validator, computed_field, field_validator, validate_call, field_serializer, ConfigDict, InstanceOf
7 |
8 | from .semver import SemVer
9 | from .shallowbinary import ShallowBinary
10 | from .binprovider import BinProvider, EnvProvider, BinaryOverrides
11 | from .base_types import (
12 | BinName,
13 | bin_abspath,
14 | bin_abspaths,
15 | HostBinPath,
16 | BinProviderName,
17 | PATHStr,
18 | Sha256,
19 | )
20 |
21 | DEFAULT_PROVIDER = EnvProvider()
22 |
23 |
24 | class Binary(ShallowBinary):
25 | model_config = ConfigDict(extra='allow', populate_by_name=True, validate_defaults=True, validate_assignment=True, from_attributes=True, revalidate_instances='always', arbitrary_types_allowed=True)
26 |
27 | name: BinName = ''
28 | description: str = ''
29 |
30 | binproviders_supported: List[InstanceOf[BinProvider]] = Field(default_factory=lambda : [DEFAULT_PROVIDER], alias='binproviders')
31 | overrides: BinaryOverrides = Field(default_factory=dict)
32 |
33 | loaded_binprovider: Optional[InstanceOf[BinProvider]] = Field(default=None, alias='binprovider')
34 | loaded_abspath: Optional[HostBinPath] = Field(default=None, alias='abspath')
35 | loaded_version: Optional[SemVer] = Field(default=None, alias='version')
36 | loaded_sha256: Optional[Sha256] = Field(default=None, alias='sha256')
37 |
38 | # bin_filename: see below
39 | # is_executable: see below
40 | # is_script
41 | # is_valid: see below
42 |
43 |
44 | @model_validator(mode='after')
45 | def validate(self):
46 | # assert self.name, 'Binary.name must not be empty'
47 | # self.description = self.description or self.name
48 |
49 | assert self.binproviders_supported, f'No providers were given for package {self.name}'
50 |
51 | # pull in any overrides from the binproviders
52 | for binprovider in self.binproviders_supported:
53 | overrides_for_bin = binprovider.overrides.get(self.name, {})
54 | if overrides_for_bin:
55 | self.overrides[binprovider.name] = {
56 | **overrides_for_bin,
57 | **self.overrides.get(binprovider.name, {}),
58 | }
59 | return self
60 |
61 | @field_validator('loaded_abspath', mode='before')
62 | def parse_abspath(cls, value: Any) -> Optional[HostBinPath]:
63 | return bin_abspath(value) if value else None
64 |
65 | @field_validator('loaded_version', mode='before')
66 | def parse_version(cls, value: Any) -> Optional[SemVer]:
67 | return SemVer(value) if value else None
68 |
69 | @field_serializer('overrides', when_used='json')
70 | def serialize_overrides(self, overrides: BinaryOverrides) -> Dict[BinProviderName, Dict[str, str]]:
71 | return {
72 | binprovider_name: {
73 | handler_type: str(handler_value)
74 | for handler_type, handler_value in binprovider_overrides.items()
75 | }
76 | for binprovider_name, binprovider_overrides in overrides.items()
77 | }
78 |
79 | @computed_field
80 | @property
81 | def loaded_abspaths(self) -> Dict[BinProviderName, List[HostBinPath]]:
82 | if not self.loaded_abspath:
83 | # binary has not been loaded yet
84 | return {}
85 |
86 | all_bin_abspaths = {self.loaded_binprovider.name: [self.loaded_abspath]} if self.loaded_binprovider else {}
87 | for binprovider in self.binproviders_supported:
88 | if not binprovider.PATH:
89 | # print('skipping provider', binprovider.name, binprovider.PATH)
90 | continue
91 | for abspath in bin_abspaths(self.name, PATH=binprovider.PATH):
92 | existing = all_bin_abspaths.get(binprovider.name, [])
93 | if abspath not in existing:
94 | all_bin_abspaths[binprovider.name] = [
95 | *existing,
96 | abspath,
97 | ]
98 | return all_bin_abspaths
99 |
100 |
101 | @computed_field
102 | @property
103 | def loaded_bin_dirs(self) -> Dict[BinProviderName, PATHStr]:
104 | return {
105 | provider_name: ':'.join([str(bin_abspath.parent) for bin_abspath in bin_abspaths])
106 | for provider_name, bin_abspaths in self.loaded_abspaths.items()
107 | }
108 |
109 | @computed_field
110 | @property
111 | def python_name(self) -> str:
112 | return self.name.replace('-', '_').replace('.', '_')
113 |
114 | # @validate_call
115 | def get_binprovider(self, binprovider_name: BinProviderName, **extra_overrides) -> InstanceOf[BinProvider]:
116 | for binprovider in self.binproviders_supported:
117 | if binprovider.name == binprovider_name:
118 | overrides_for_binprovider = {
119 | self.name: self.overrides.get(binprovider_name, {})
120 | }
121 | return binprovider.get_provider_with_overrides(overrides=overrides_for_binprovider, **extra_overrides)
122 |
123 | raise KeyError(f'{binprovider_name} is not a supported BinProvider for Binary(name={self.name})')
124 |
125 | @validate_call
126 | def install(self, binproviders: Optional[List[BinProviderName]]=None, **extra_overrides) -> Self:
127 | assert self.name, f'No binary name was provided! {self}'
128 |
129 | if binproviders is not None and len(list(binproviders)) == 0:
130 | return self
131 |
132 |
133 | inner_exc = Exception('No providers were available')
134 | errors = {}
135 | for binprovider in self.binproviders_supported:
136 | if binproviders and (binprovider.name not in binproviders):
137 | continue
138 |
139 | try:
140 | provider = self.get_binprovider(binprovider_name=binprovider.name, **extra_overrides)
141 |
142 | installed_bin = provider.install(self.name)
143 | if installed_bin is not None and installed_bin.loaded_abspath:
144 | # print('INSTALLED', self.name, installed_bin)
145 | return self.__class__(**{
146 | **self.model_dump(),
147 | **installed_bin.model_dump(exclude={'binproviders_supported'}),
148 | 'loaded_binprovider': provider,
149 | 'binproviders_supported': self.binproviders_supported,
150 | 'overrides': self.overrides,
151 | })
152 | except Exception as err:
153 | # print(err)
154 | # raise
155 | inner_exc = err
156 | errors[binprovider.name] = str(err)
157 |
158 | provider_names = ', '.join(binproviders or [p.name for p in self.binproviders_supported])
159 | raise Exception(f'None of the configured providers ({provider_names}) were able to install binary: {self.name} ERRORS={errors}') from inner_exc
160 |
161 | @validate_call
162 | def load(self, binproviders: Optional[List[BinProviderName]]=None, nocache=False, **extra_overrides) -> Self:
163 | assert self.name, f'No binary name was provided! {self}'
164 |
165 | # if we're already loaded, skip loading
166 | if self.is_valid:
167 | return self
168 |
169 | # if binproviders list is passed but it's empty, skip loading
170 | if binproviders is not None and len(list(binproviders)) == 0:
171 | return self
172 |
173 | inner_exc = Exception('No providers were available')
174 | errors = {}
175 | for binprovider in self.binproviders_supported:
176 | if binproviders and binprovider.name not in binproviders:
177 | continue
178 |
179 | try:
180 | provider = self.get_binprovider(binprovider_name=binprovider.name, **extra_overrides)
181 |
182 | installed_bin = provider.load(self.name, nocache=nocache)
183 | if installed_bin is not None and installed_bin.loaded_abspath:
184 | # print('LOADED', binprovider, self.name, installed_bin)
185 | return self.__class__(**{
186 | **self.model_dump(),
187 | **installed_bin.model_dump(exclude={'binproviders_supported'}),
188 | 'loaded_binprovider': provider,
189 | 'binproviders_supported': self.binproviders_supported,
190 | 'overrides': self.overrides,
191 | })
192 | else:
193 | continue
194 | except Exception as err:
195 | # print(err)
196 | inner_exc = err
197 | errors[binprovider.name] = str(err)
198 |
199 | provider_names = ', '.join(binproviders or [p.name for p in self.binproviders_supported])
200 | raise Exception(f'None of the configured providers ({provider_names}) were able to load binary: {self.name} ERRORS={errors}') from inner_exc
201 |
202 | @validate_call
203 | def load_or_install(self, binproviders: Optional[List[BinProviderName]]=None, nocache: bool=False, **extra_overrides) -> Self:
204 | assert self.name, f'No binary name was provided! {self}'
205 |
206 | if self.is_valid:
207 | return self
208 |
209 | if binproviders is not None and len(list(binproviders)) == 0:
210 | return self
211 |
212 | inner_exc = Exception('No providers were available')
213 | errors = {}
214 | for binprovider in self.binproviders_supported:
215 | if binproviders and binprovider.name not in binproviders:
216 | continue
217 |
218 | try:
219 | provider = self.get_binprovider(binprovider_name=binprovider.name, **extra_overrides)
220 |
221 | installed_bin = provider.load_or_install(self.name, nocache=nocache)
222 | if installed_bin is not None and installed_bin.loaded_abspath:
223 | # print('LOADED_OR_INSTALLED', self.name, installed_bin)
224 | return self.__class__(**{
225 | **self.model_dump(),
226 | **installed_bin.model_dump(exclude={'binproviders_supported'}),
227 | 'loaded_binprovider': provider,
228 | 'binproviders_supported': self.binproviders_supported,
229 | 'overrides': self.overrides,
230 | })
231 | else:
232 | continue
233 | except Exception as err:
234 | # print(err)
235 | inner_exc = err
236 | errors[binprovider.name] = str(err)
237 | continue
238 |
239 | provider_names = ', '.join(binproviders or [p.name for p in self.binproviders_supported])
240 | raise Exception(f'None of the configured providers ({provider_names}) were able to find or install binary: {self.name} ERRORS={errors}') from inner_exc
241 |
--------------------------------------------------------------------------------
/abx_pkg/binprovider.py:
--------------------------------------------------------------------------------
1 | __package__ = "abx_pkg"
2 |
3 | import os
4 | import sys
5 | import pwd
6 | import shutil
7 | import hashlib
8 | import platform
9 | import subprocess
10 | import functools
11 |
12 | from typing import Callable, Optional, Iterable, List, cast, final, Dict, Any, Tuple, Literal, Protocol, runtime_checkable
13 |
14 | from typing_extensions import Self, TypedDict
15 | from pathlib import Path
16 |
17 | from pydantic_core import ValidationError
18 | from pydantic import BaseModel, Field, TypeAdapter, validate_call, ConfigDict, InstanceOf, computed_field, model_validator
19 |
20 | from .semver import SemVer
21 | from .base_types import (
22 | BinName,
23 | BinDirPath,
24 | HostBinPath,
25 | BinProviderName,
26 | PATHStr,
27 | InstallArgs,
28 | Sha256,
29 | SelfMethodName,
30 | UNKNOWN_SHA256,
31 | bin_name,
32 | path_is_executable,
33 | path_is_script,
34 | bin_abspath,
35 | bin_abspaths,
36 | func_takes_args_or_kwargs,
37 | )
38 |
39 | ################## GLOBALS ##########################################
40 |
41 | OPERATING_SYSTEM = platform.system().lower()
42 | DEFAULT_PATH = "/home/linuxbrew/.linuxbrew/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
43 | DEFAULT_ENV_PATH = os.environ.get("PATH", DEFAULT_PATH)
44 | PYTHON_BIN_DIR = str(Path(sys.executable).parent)
45 |
46 | if PYTHON_BIN_DIR not in DEFAULT_ENV_PATH:
47 | DEFAULT_ENV_PATH = PYTHON_BIN_DIR + ":" + DEFAULT_ENV_PATH
48 |
49 | UNKNOWN_ABSPATH = Path('/usr/bin/true')
50 | UNKNOWN_VERSION = cast(SemVer, SemVer.parse('999.999.999'))
51 |
52 | ################## VALIDATORS #######################################
53 |
54 | NEVER_CACHE = (
55 | None,
56 | UNKNOWN_ABSPATH,
57 | UNKNOWN_VERSION,
58 | UNKNOWN_SHA256,
59 | )
60 |
61 | def binprovider_cache(binprovider_method):
62 | """cache non-null return values for BinProvider methods on the BinProvider instance"""
63 |
64 | method_name = binprovider_method.__name__
65 |
66 | @functools.wraps(binprovider_method)
67 | def cached_function(self, bin_name: BinName, **kwargs):
68 | self._cache = self._cache or {}
69 | self._cache[method_name] = self._cache.get(method_name, {})
70 | method_cache = self._cache[method_name]
71 |
72 | if bin_name in method_cache and not kwargs.get('nocache'):
73 | # print('USING CACHED VALUE:', f'{self.__class__.__name__}.{method_name}({bin_name}, {kwargs}) -> {method_cache[bin_name]}')
74 | return method_cache[bin_name]
75 |
76 | return_value = binprovider_method(self, bin_name, **kwargs)
77 |
78 | if return_value and return_value not in NEVER_CACHE:
79 | self._cache[method_name][bin_name] = return_value
80 | return return_value
81 |
82 | cached_function.__name__ = f'{method_name}_cached'
83 |
84 | return cached_function
85 |
86 |
87 |
88 | class ShallowBinary(BaseModel):
89 | """
90 | Shallow version of Binary used as a return type for BinProvider methods (e.g. load_or_install()).
91 | (doesn't implement full Binary interface, but can be used to populate a full loaded Binary instance)
92 | """
93 |
94 | model_config = ConfigDict(extra="forbid", populate_by_name=True, validate_defaults=True, validate_assignment=False, from_attributes=True, arbitrary_types_allowed=True)
95 |
96 | name: BinName = ""
97 | description: str = ""
98 |
99 | binproviders_supported: List[InstanceOf["BinProvider"]] = Field(default_factory=list, alias="binproviders")
100 | overrides: 'BinaryOverrides' = Field(default_factory=dict)
101 |
102 | loaded_binprovider: InstanceOf["BinProvider"] = Field(alias="binprovider")
103 | loaded_abspath: HostBinPath = Field(alias="abspath")
104 | loaded_version: SemVer = Field(alias="version")
105 | loaded_sha256: Sha256 = Field(alias="sha256")
106 |
107 | def __getattr__(self, item):
108 | """Allow accessing fields as attributes by both field name and alias name"""
109 | for field, meta in self.model_fields.items():
110 | if meta.alias == item:
111 | return getattr(self, field)
112 | return super().__getattr__(item)
113 |
114 | @model_validator(mode="after")
115 | def validate(self) -> Self:
116 | self.description = self.description or self.name
117 | return self
118 |
119 | @computed_field # type: ignore[misc] # see mypy issue #1362
120 | @property
121 | def bin_filename(self) -> BinName:
122 | if self.is_script:
123 | # e.g. '.../Python.framework/Versions/3.11/lib/python3.11/sqlite3/__init__.py' -> sqlite
124 | name = self.name
125 | elif self.loaded_abspath:
126 | # e.g. '/opt/homebrew/bin/wget' -> wget
127 | name = bin_name(self.loaded_abspath)
128 | else:
129 | # e.g. 'ytdlp' -> 'yt-dlp'
130 | name = bin_name(self.name)
131 | return name
132 |
133 | @computed_field # type: ignore[misc] # see mypy issue #1362
134 | @property
135 | def is_executable(self) -> bool:
136 | try:
137 | assert self.loaded_abspath and path_is_executable(self.loaded_abspath)
138 | return True
139 | except (ValidationError, AssertionError):
140 | return False
141 |
142 | @computed_field # type: ignore[misc] # see mypy issue #1362
143 | @property
144 | def is_script(self) -> bool:
145 | try:
146 | assert self.loaded_abspath and path_is_script(self.loaded_abspath)
147 | return True
148 | except (ValidationError, AssertionError):
149 | return False
150 |
151 | @computed_field # type: ignore[misc] # see mypy issue #1362
152 | @property
153 | def is_valid(self) -> bool:
154 | return bool(self.name and self.loaded_abspath and self.loaded_version and (self.is_executable or self.is_script))
155 |
156 | @computed_field
157 | @property
158 | def bin_dir(self) -> BinDirPath | None:
159 | if not self.loaded_abspath:
160 | return None
161 | return TypeAdapter(BinDirPath).validate_python(self.loaded_abspath.parent)
162 |
163 | @computed_field
164 | @property
165 | def loaded_respath(self) -> HostBinPath | None:
166 | return self.loaded_abspath and self.loaded_abspath.resolve()
167 |
168 | # @validate_call
169 | def exec(
170 | self, bin_name: BinName | HostBinPath | None = None, cmd: Iterable[str | Path | int | float | bool] = (), cwd: str | Path = ".", quiet=False, **kwargs
171 | ) -> subprocess.CompletedProcess:
172 | bin_name = str(bin_name or self.loaded_abspath or self.name)
173 | if bin_name == self.name:
174 | assert self.loaded_abspath, "Binary must have a loaded_abspath, make sure to load_or_install() first"
175 | assert self.loaded_version, "Binary must have a loaded_version, make sure to load_or_install() first"
176 | assert os.path.isdir(cwd) and os.access(cwd, os.R_OK), f"cwd must be a valid, accessible directory: {cwd}"
177 | cmd = [str(bin_name), *(str(arg) for arg in cmd)]
178 | if not quiet:
179 | print('$', ' '.join(cmd), file=sys.stderr)
180 | return subprocess.run(cmd, capture_output=True, text=True, cwd=str(cwd), **kwargs)
181 |
182 |
183 | DEFAULT_OVERRIDES = {
184 | '*': {
185 | 'version': 'self.default_version_handler',
186 | 'abspath': 'self.default_abspath_handler',
187 | 'packages': 'self.default_packages_handler',
188 | 'install': 'self.default_install_handler',
189 | },
190 | }
191 |
192 |
193 | class BinProvider(BaseModel):
194 | model_config = ConfigDict(extra='forbid', populate_by_name=True, validate_defaults=True, validate_assignment=False, from_attributes=True, revalidate_instances='always', arbitrary_types_allowed=True)
195 | name: BinProviderName = ''
196 |
197 | PATH: PATHStr = Field(default=str(Path(sys.executable).parent), repr=False) # e.g. '/opt/homebrew/bin:/opt/archivebox/bin'
198 | INSTALLER_BIN: BinName = 'env'
199 |
200 | euid: Optional[int] = None
201 |
202 | overrides: 'BinProviderOverrides' = Field(default=DEFAULT_OVERRIDES, repr=False, exclude=True)
203 |
204 | _dry_run: bool = False
205 | _install_timeout: int = 120
206 | _version_timeout: int = 10
207 | _cache: Dict[str, Dict[str, Any]] | None = None
208 | _INSTALLER_BIN_ABSPATH: HostBinPath | None = None # speed optimization only, faster to cache the abspath than to recompute it on every access
209 | _INSTALLER_BINARY: ShallowBinary | None = None # speed optimization only, faster to cache the binary than to recompute it on every access
210 |
211 | def __eq__(self, other: Any) -> bool:
212 | try:
213 | return dict(self) == dict(other) # only compare pydantic fields, ignores classvars/@properties/@cached_properties/_fields/etc.
214 | except Exception:
215 | return False
216 |
217 | @property
218 | def EUID(self) -> int:
219 | """
220 | Detect the user (UID) to run as when executing this binprovider's INSTALLER_BIN
221 | e.g. homebrew should never be run as root, we can tell which user to run it as by looking at who owns its binary
222 | apt should always be run as root, pip should be run as the user that owns the venv, etc.
223 | """
224 |
225 | # use user-provided value if one is set
226 | if self.euid is not None:
227 | return self.euid
228 |
229 | # fallback to owner of installer binary
230 | try:
231 | installer_bin = self.INSTALLER_BIN_ABSPATH
232 | if installer_bin:
233 | return os.stat(installer_bin).st_uid
234 | except Exception:
235 | # INSTALLER_BIN_ABSPATH is not always availabe (e.g. at import time, or if it dynamically changes)
236 | pass
237 |
238 | # fallback to current user
239 | return os.geteuid()
240 |
241 |
242 | @computed_field
243 | @property
244 | def INSTALLER_BIN_ABSPATH(self) -> HostBinPath | None:
245 | """Actual absolute path of the underlying package manager (e.g. /usr/local/bin/npm)"""
246 | if self._INSTALLER_BIN_ABSPATH:
247 | # return cached value if we have one
248 | return self._INSTALLER_BIN_ABSPATH
249 |
250 | abspath = bin_abspath(self.INSTALLER_BIN, PATH=self.PATH) or bin_abspath(self.INSTALLER_BIN) # find self.INSTALLER_BIN abspath using environment path
251 | if not abspath:
252 | # underlying package manager not found on this host, return None
253 | return None
254 |
255 | valid_abspath = TypeAdapter(HostBinPath).validate_python(abspath)
256 | if valid_abspath:
257 | # if we found a valid abspath, cache it
258 | self._INSTALLER_BIN_ABSPATH = valid_abspath
259 | return valid_abspath
260 |
261 | @property
262 | def INSTALLER_BINARY(self) -> ShallowBinary | None:
263 | """Get the loaded binary for this binprovider's INSTALLER_BIN"""
264 |
265 | if self._INSTALLER_BINARY:
266 | # return cached value if we have one
267 | return self._INSTALLER_BINARY
268 |
269 | abspath = self.INSTALLER_BIN_ABSPATH
270 | if not abspath:
271 | return None
272 |
273 | try:
274 | # try loading it from the BinProvider's own PATH (e.g. ~/test/.venv/bin/pip)
275 | loaded_bin = self.load(bin_name=self.INSTALLER_BIN)
276 | if loaded_bin:
277 | self._INSTALLER_BINARY = loaded_bin
278 | return loaded_bin
279 | except Exception:
280 | pass
281 |
282 | env = EnvProvider()
283 | try:
284 | # try loading it from the env provider (e.g. /opt/homebrew/bin/pip)
285 | loaded_bin = env.load(bin_name=self.INSTALLER_BIN)
286 | if loaded_bin:
287 | self._INSTALLER_BINARY = loaded_bin
288 | return loaded_bin
289 | except Exception:
290 | pass
291 |
292 | version = UNKNOWN_VERSION
293 | sha256 = UNKNOWN_SHA256
294 |
295 | return ShallowBinary(
296 | name=self.INSTALLER_BIN,
297 | abspath=abspath,
298 | binprovider=env,
299 | version=version,
300 | sha256=sha256,
301 | )
302 |
303 | @computed_field
304 | @property
305 | def is_valid(self) -> bool:
306 | return bool(self.INSTALLER_BIN_ABSPATH)
307 |
308 | @final
309 | # @validate_call(config={'arbitrary_types_allowed': True})
310 | def get_provider_with_overrides(self, overrides: Optional['BinProviderOverrides']=None, dry_run: bool=False, install_timeout: int | None=None, version_timeout: int | None=None) -> Self:
311 | # created an updated copy of the BinProvider with the overrides applied, then get the handlers on it.
312 | # important to do this so that any subsequent calls to handler functions down the call chain
313 | # still have access to the overrides, we don't have to have to pass them down as args all the way down the stack
314 |
315 | updated_binprovider: Self = self.model_copy()
316 |
317 | # main binary-specific overrides for [abspath, version, packages, install]
318 | overrides = overrides or {}
319 |
320 | # extra overrides that are also configurable, can add more in the future as-needed for tunable options
321 | updated_binprovider._dry_run = dry_run
322 | updated_binprovider._install_timeout = install_timeout or self._install_timeout
323 | updated_binprovider._version_timeout = version_timeout or self._version_timeout
324 |
325 | # overrides = {
326 | # 'wget': {
327 | # 'packages': lambda: ['wget'],
328 | # 'abspath': lambda: shutil.which('wget'),
329 | # 'version': lambda: SemVer.parse(os.system('wget --version')),
330 | # 'install': lambda: os.system('brew install wget'),
331 | # },
332 | # }
333 | for binname, bin_overrides in overrides.items():
334 | updated_binprovider.overrides[binname] = {
335 | **updated_binprovider.overrides.get(binname, {}),
336 | **bin_overrides,
337 | }
338 |
339 | return updated_binprovider
340 |
341 |
342 | # @validate_call
343 | def _get_handler_for_action(self, bin_name: BinName, handler_type: 'HandlerType') -> Callable[..., 'HandlerReturnValue']:
344 | """
345 | Get the handler func for a given key + Dict of handler callbacks + fallback default handler.
346 | e.g. _get_handler_for_action(bin_name='yt-dlp', 'install', default_handler=self.default_install_handler, ...) -> Callable
347 | """
348 |
349 | # e.g. {'yt-dlp': {'install': 'self.default_install_handler'}} -> 'self.default_install_handler'
350 | handler: HandlerValue = (
351 | self.overrides.get(bin_name, {}).get(handler_type)
352 | or self.overrides.get('*', {}).get(handler_type)
353 | )
354 | # print('getting handler for action', bin_name, handler_type, handler_func)
355 | assert handler, f'BinProvider(name={self.name}) has no {handler_type} handler implemented for Binary(name={bin_name})'
356 |
357 | # if handler_func is already a callable, return it directly
358 | if isinstance(handler, Callable):
359 | handler_func: Callable[..., HandlerReturnValue] = handler
360 | return handler_func
361 |
362 | # if handler_func is string reference to a function on self, swap it for the actual function
363 | elif isinstance(handler, str) and (handler.startswith('self.') or handler.startswith('BinProvider.')):
364 | # special case, allow dotted path references to methods on self (where self refers to the BinProvider)
365 | handler_method: Callable[..., HandlerReturnValue] = getattr(self, handler.split('self.', 1)[-1])
366 | return handler_method
367 |
368 | # if handler_func is any other value, treat is as a literal and return a func that provides the literal
369 | literal_value = TypeAdapter(HandlerReturnValue).validate_python(handler)
370 | handler_func: Callable[..., HandlerReturnValue] = lambda: literal_value # noqa: E731
371 | return handler_func
372 |
373 | # @validate_call
374 | def _call_handler_for_action(self, bin_name: BinName, handler_type: 'HandlerType', **kwargs) -> 'HandlerReturnValue':
375 | handler_func: Callable[..., HandlerReturnValue] = self._get_handler_for_action(
376 | bin_name=bin_name, # e.g. 'yt-dlp', or 'wget', etc.
377 | handler_type=handler_type, # e.g. abspath, version, packages, install
378 | )
379 |
380 | # def timeout_handler(signum, frame):
381 | # raise TimeoutError(f'{self.__class__.__name__} Timeout while running {handler_type} for Binary {bin_name}')
382 |
383 | # signal ONLY WORKS IN MAIN THREAD, not a viable solution for timeout enforcement! breaks in prod
384 | # signal.signal(signal.SIGALRM, handler=timeout_handler)
385 | # signal.alarm(timeout)
386 | try:
387 | if not func_takes_args_or_kwargs(handler_func):
388 | # if it's a pure argless lambda/func, dont pass bin_path and other **kwargs
389 | handler_func_without_args = cast(Callable[[], HandlerReturnValue], handler_func)
390 | return handler_func_without_args()
391 |
392 | handler_func = cast(Callable[..., HandlerReturnValue], handler_func)
393 | if hasattr(handler_func, '__self__'):
394 | # func is already a method bound to self, just call it directly
395 | return handler_func(bin_name, **kwargs)
396 | else:
397 | # func is not bound to anything, pass BinProvider as first arg
398 | return handler_func(self, bin_name, **kwargs)
399 | except TimeoutError:
400 | raise
401 | # finally:
402 | # signal.alarm(0)
403 |
404 |
405 | # DEFAULT HANDLERS, override these in subclasses as needed:
406 |
407 | # @validate_call
408 | def default_abspath_handler(self, bin_name: BinName | HostBinPath, **context) -> 'AbspathFuncReturnValue': # aka str | Path | None
409 | # print(f'[*] {self.__class__.__name__}: Getting abspath for {bin_name}...')
410 |
411 | if not self.PATH:
412 | return None
413 |
414 | return bin_abspath(bin_name, PATH=self.PATH)
415 |
416 | # @validate_call
417 | def default_version_handler(self, bin_name: BinName, abspath: Optional[HostBinPath]=None, **context) -> 'VersionFuncReturnValue': # aka List[str] | Tuple[str, ...]
418 |
419 | abspath = abspath or self.get_abspath(bin_name, quiet=True)
420 | if not abspath:
421 | return None
422 |
423 | # print(f'[*] {self.__class__.__name__}: Getting version for {bin_name}...')
424 |
425 | validation_err = None
426 |
427 | # Attempt 1: $ --version
428 | dash_dash_version_result = self.exec(bin_name=abspath, cmd=['--version'], timeout=self._version_timeout, quiet=True)
429 | dash_dash_version_out = dash_dash_version_result.stdout.strip()
430 | try:
431 | version = SemVer.parse(dash_dash_version_out)
432 | assert version, f"Could not parse version from $ {bin_name} --version: {dash_dash_version_result.stdout}\n{dash_dash_version_result.stderr}\n".strip()
433 | return version
434 | except (ValidationError, AssertionError) as err:
435 | validation_err = err
436 |
437 | # Attempt 2: $ -version
438 | dash_version_out = self.exec(bin_name=abspath, cmd=["-version"], timeout=self._version_timeout, quiet=True).stdout.strip()
439 | try:
440 | version = SemVer.parse(dash_version_out)
441 | assert version, f"Could not parse version from $ {bin_name} -version: {dash_version_out}".strip()
442 | return version
443 | except (ValidationError, AssertionError) as err:
444 | validation_err = validation_err or err
445 |
446 | # Attempt 3: $ -v
447 | dash_v_out = self.exec(bin_name=abspath, cmd=["-v"], timeout=self._version_timeout, quiet=True).stdout.strip()
448 | try:
449 | version = SemVer.parse(dash_v_out)
450 | assert version, f"Could not parse version from $ {bin_name} -v: {dash_v_out}".strip()
451 | return version
452 | except (ValidationError, AssertionError) as err:
453 | validation_err = validation_err or err
454 |
455 | raise ValueError(
456 | f"Unable to find {bin_name} version from {bin_name} --version, -version or -v output\n{dash_dash_version_out or dash_version_out or dash_v_out}".strip()
457 | ) from validation_err
458 |
459 | # @validate_call
460 | def default_packages_handler(self, bin_name: BinName, **context) -> 'PackagesFuncReturnValue': # aka List[str] aka InstallArgs
461 | # print(f'[*] {self.__class__.__name__}: Getting install command for {bin_name}')
462 | # ... install command calculation logic here
463 | return [bin_name]
464 |
465 | # @validate_call
466 | def default_install_handler(self, bin_name: BinName, packages: Optional[InstallArgs]=None, **context) -> 'InstallFuncReturnValue': # aka str
467 | self.setup()
468 | packages = packages or self.get_packages(bin_name)
469 | if not self.INSTALLER_BIN_ABSPATH:
470 | raise Exception(f'{self.name} install method is not available on this host ({self.INSTALLER_BIN} not found in $PATH)')
471 |
472 | # print(f'[*] {self.__class__.__name__}: Installing {bin_name}: {self.INSTALLER_BIN_ABSPATH} {packages}')
473 |
474 | # ... override the default install logic here ...
475 |
476 | # proc = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=['install', *packages], timeout=self._install_timeout)
477 | # if not proc.returncode == 0:
478 | # print(proc.stdout.strip())
479 | # print(proc.stderr.strip())
480 | # raise Exception(f'{self.name} Failed to install {bin_name}: {proc.stderr.strip()}\n{proc.stdout.strip()}')
481 |
482 | return f'{self.name} BinProvider does not implement any install method'
483 |
484 |
485 | def setup_PATH(self) -> None:
486 | for path in reversed(self.PATH.split(':')):
487 | if path not in sys.path:
488 | sys.path.insert(0, path) # e.g. /opt/archivebox/bin:/bin:/usr/local/bin:...
489 |
490 | # @validate_call
491 | def exec(self, bin_name: BinName | HostBinPath, cmd: Iterable[str | Path | int | float | bool]=(), cwd: Path | str='.', quiet=False, **kwargs) -> subprocess.CompletedProcess:
492 | if shutil.which(str(bin_name)):
493 | bin_abspath = bin_name
494 | else:
495 | bin_abspath = self.get_abspath(str(bin_name))
496 | assert bin_abspath, f'BinProvider {self.name} cannot execute bin_name {bin_name} because it could not find its abspath. (Did {self.__class__.__name__}.load_or_install({bin_name}) fail?)'
497 | assert os.access(cwd, os.R_OK) and os.path.isdir(cwd), f'cwd must be a valid, accessible directory: {cwd}'
498 | cmd = [str(bin_abspath), *(str(arg) for arg in cmd)]
499 | if not quiet:
500 | prefix = 'DRY RUN: $' if self._dry_run else '$'
501 | print(prefix, ' '.join(cmd), file=sys.stderr)
502 |
503 | # https://stackoverflow.com/a/6037494/2156113
504 | # copy env and modify it to run the subprocess as the the designated user
505 | env = kwargs.get('env', {}) or os.environ.copy()
506 | pw_record = pwd.getpwuid(self.EUID)
507 | run_as_uid = pw_record.pw_uid
508 | run_as_gid = pw_record.pw_gid
509 | # update environment variables so that subprocesses dont try to write to /root home directory
510 | # for things like cache dirs, logs, etc. npm/pip/etc. often try to write to $HOME
511 | env['PWD'] = str(cwd)
512 | env['HOME'] = pw_record.pw_dir
513 | env['LOGNAME'] = pw_record.pw_name
514 | env['USER'] = pw_record.pw_name
515 |
516 | def drop_privileges():
517 | try:
518 | os.setuid(run_as_uid)
519 | os.setgid(run_as_gid)
520 | except Exception:
521 | pass
522 |
523 | if self._dry_run:
524 | return subprocess.CompletedProcess(cmd, 0, '', 'skipped (dry run)')
525 |
526 | return subprocess.run(cmd, capture_output=True, text=True, cwd=str(cwd), env=env, preexec_fn=drop_privileges, **kwargs)
527 |
528 |
529 | # CALLING API, DONT OVERRIDE THESE:
530 |
531 | @final
532 | @binprovider_cache
533 | # @validate_call
534 | def get_abspaths(self, bin_name: BinName, nocache: bool=False) -> List[HostBinPath]:
535 | return bin_abspaths(bin_name, PATH=self.PATH)
536 |
537 | @final
538 | @binprovider_cache
539 | # @validate_call
540 | def get_sha256(self, bin_name: BinName, abspath: Optional[HostBinPath]=None, nocache: bool=False) -> Sha256 | None:
541 | """Get the sha256 hash of the binary at the given abspath (or equivalent hash of the underlying package)"""
542 |
543 | abspath = abspath or self.get_abspath(bin_name, nocache=nocache)
544 | if not abspath or not os.access(abspath, os.R_OK):
545 | return None
546 |
547 | if sys.version_info >= (3, 11):
548 | with open(abspath, "rb", buffering=0) as f:
549 | return TypeAdapter(Sha256).validate_python(hashlib.file_digest(f, 'sha256').hexdigest())
550 |
551 | hash_sha256 = hashlib.sha256()
552 | with open(abspath, "rb") as f:
553 | for chunk in iter(lambda: f.read(4096), b""):
554 | hash_sha256.update(chunk)
555 | return TypeAdapter(Sha256).validate_python(hash_sha256.hexdigest())
556 |
557 | @final
558 | @binprovider_cache
559 | # @validate_call
560 | def get_abspath(self, bin_name: BinName, quiet: bool=False, nocache: bool=False) -> HostBinPath | None:
561 | self.setup_PATH()
562 | abspath = None
563 | try:
564 | abspath = cast(AbspathFuncReturnValue, self._call_handler_for_action(bin_name=bin_name, handler_type='abspath'))
565 | except Exception:
566 | if not quiet:
567 | raise
568 | if not abspath:
569 | return None
570 | result = TypeAdapter(HostBinPath).validate_python(abspath)
571 | return result
572 |
573 | @final
574 | @binprovider_cache
575 | # @validate_call
576 | def get_version(self, bin_name: BinName, abspath: Optional[HostBinPath]=None, quiet: bool=False, nocache: bool=False) -> SemVer | None:
577 | version = None
578 | try:
579 | version = cast(VersionFuncReturnValue, self._call_handler_for_action(bin_name=bin_name, handler_type='version', abspath=abspath))
580 | except Exception:
581 | if not quiet:
582 | raise
583 |
584 | if not version:
585 | return None
586 |
587 | if not isinstance(version, SemVer):
588 | version = SemVer.parse(version)
589 |
590 | return version
591 |
592 | @final
593 | @binprovider_cache
594 | # @validate_call
595 | def get_packages(self, bin_name: BinName, quiet: bool=False, nocache: bool=False) -> InstallArgs:
596 | packages = None
597 | try:
598 | packages = cast(PackagesFuncReturnValue, self._call_handler_for_action(bin_name=bin_name, handler_type='packages'))
599 | except Exception:
600 | if not quiet:
601 | raise
602 |
603 | if not packages:
604 | packages = [bin_name]
605 | result = TypeAdapter(InstallArgs).validate_python(packages)
606 | return result
607 |
608 | def setup(self) -> None:
609 | """Override this to do any setup steps needed before installing packaged (e.g. create a venv, init an npm prefix, etc.)"""
610 | pass
611 |
612 | @final
613 | @binprovider_cache
614 | @validate_call
615 | def install(self, bin_name: BinName, quiet: bool=False, nocache: bool=False) -> ShallowBinary | None:
616 | self.setup()
617 |
618 | packages = self.get_packages(bin_name, quiet=quiet, nocache=nocache)
619 |
620 | self.setup_PATH()
621 | install_log = None
622 | try:
623 | install_log = cast(InstallFuncReturnValue, self._call_handler_for_action(bin_name=bin_name, handler_type='install', packages=packages))
624 | except Exception as err:
625 | install_log = f'{self.__class__.__name__} Failed to install {bin_name}, got {err.__class__.__name__}: {err}'
626 | if not quiet:
627 | raise
628 |
629 | if self._dry_run:
630 | # return fake ShallowBinary if we're just doing a dry run
631 | # no point trying to get real abspath or version if nothing was actually installed
632 | return ShallowBinary(
633 | name=bin_name,
634 | binprovider=self,
635 | abspath=Path(shutil.which(bin_name) or UNKNOWN_ABSPATH),
636 | version=cast(SemVer, UNKNOWN_VERSION),
637 | sha256=UNKNOWN_SHA256, binproviders=[self],
638 | )
639 |
640 | installed_abspath = self.get_abspath(bin_name, quiet=True, nocache=nocache)
641 | if not quiet:
642 | assert installed_abspath, f'{self.__class__.__name__} Unable to find abspath for {bin_name} after installing. PATH={self.PATH} LOG={install_log}'
643 |
644 | installed_version = self.get_version(bin_name, abspath=installed_abspath, quiet=True, nocache=nocache)
645 | if not quiet:
646 | assert installed_version, f'{self.__class__.__name__} Unable to find version for {bin_name} after installing. ABSPATH={installed_abspath} LOG={install_log}'
647 |
648 | sha256 = self.get_sha256(bin_name, abspath=installed_abspath, nocache=nocache) or UNKNOWN_SHA256
649 |
650 | if (installed_abspath and installed_version):
651 | # installed binary is valid and ready to use
652 | result = ShallowBinary(
653 | name=bin_name,
654 | binprovider=self,
655 | abspath=installed_abspath,
656 | version=installed_version,
657 | sha256=sha256,
658 | binproviders=[self],
659 | )
660 | else:
661 | result = None
662 |
663 | return result
664 |
665 | @final
666 | @validate_call
667 | def load(self, bin_name: BinName, quiet: bool=True, nocache: bool=False) -> ShallowBinary | None:
668 | installed_abspath = self.get_abspath(bin_name, quiet=quiet, nocache=nocache)
669 | if not installed_abspath:
670 | return None
671 |
672 | installed_version = self.get_version(bin_name, abspath=installed_abspath, quiet=quiet, nocache=nocache)
673 | if not installed_version:
674 | return None
675 |
676 | sha256 = self.get_sha256(bin_name, abspath=installed_abspath) or UNKNOWN_SHA256 # not ideal to store UNKNOWN_SHA256but it's better than nothing and this value isnt critical
677 |
678 | return ShallowBinary(
679 | name=bin_name,
680 | binprovider=self,
681 | abspath=installed_abspath,
682 | version=installed_version,
683 | sha256=sha256,
684 | binproviders=[self],
685 | )
686 |
687 | @final
688 | @validate_call
689 | def load_or_install(self, bin_name: BinName, quiet: bool=False, nocache: bool=False) -> ShallowBinary | None:
690 | installed = self.load(bin_name=bin_name, quiet=True, nocache=nocache)
691 | if not installed:
692 | installed = self.install(bin_name=bin_name, quiet=quiet, nocache=nocache)
693 | return installed
694 |
695 |
696 |
697 | class EnvProvider(BinProvider):
698 | name: BinProviderName = 'env'
699 | INSTALLER_BIN: BinName = 'which'
700 | PATH: PATHStr = DEFAULT_ENV_PATH # add dir containing python to $PATH
701 |
702 | overrides: 'BinProviderOverrides' = {
703 | '*': {
704 | **BinProvider.model_fields['overrides'].default['*'],
705 | 'install': 'self.install_noop',
706 | },
707 | 'python': {
708 | 'abspath': Path(sys.executable),
709 | 'version': '{}.{}.{}'.format(*sys.version_info[:3]),
710 | },
711 | }
712 |
713 | def install_noop(self, bin_name: BinName, packages: Optional[InstallArgs]=None, **context) -> str:
714 | """The env BinProvider is ready-only and does not install any packages, so this is a no-op"""
715 | return 'env is ready-only and just checks for existing binaries in $PATH'
716 |
717 | ############################################################################################################
718 |
719 |
720 |
721 | AbspathFuncReturnValue = str | HostBinPath | None
722 | VersionFuncReturnValue = str | Tuple[int, ...] | Tuple[str, ...] | SemVer | None # SemVer is a subclass of NamedTuple
723 | PackagesFuncReturnValue = List[str] | Tuple[str, ...] | str | InstallArgs | None
724 | InstallFuncReturnValue = str | None
725 | ProviderFuncReturnValue = AbspathFuncReturnValue | VersionFuncReturnValue | PackagesFuncReturnValue | InstallFuncReturnValue
726 |
727 | @runtime_checkable
728 | class AbspathFuncWithArgs(Protocol):
729 | def __call__(_self, binprovider: 'BinProvider', bin_name: BinName, **context) -> 'AbspathFuncReturnValue':
730 | ...
731 |
732 | @runtime_checkable
733 | class VersionFuncWithArgs(Protocol):
734 | def __call__(_self, binprovider: 'BinProvider', bin_name: BinName, **context) -> 'VersionFuncReturnValue':
735 | ...
736 |
737 | @runtime_checkable
738 | class PackagesFuncWithArgs(Protocol):
739 | def __call__(_self, binprovider: 'BinProvider', bin_name: BinName, **context) -> 'PackagesFuncReturnValue':
740 | ...
741 |
742 | @runtime_checkable
743 | class InstallFuncWithArgs(Protocol):
744 | def __call__(_self, binprovider: 'BinProvider', bin_name: BinName, **context) -> 'InstallFuncReturnValue':
745 | ...
746 |
747 | AbspathFuncWithNoArgs = Callable[[], AbspathFuncReturnValue]
748 | VersionFuncWithNoArgs = Callable[[], VersionFuncReturnValue]
749 | PackagesFuncWithNoArgs = Callable[[], PackagesFuncReturnValue]
750 | InstallFuncWithNoArgs = Callable[[], InstallFuncReturnValue]
751 |
752 | AbspathHandlerValue = SelfMethodName | AbspathFuncWithNoArgs | AbspathFuncWithArgs | AbspathFuncReturnValue
753 | VersionHandlerValue = SelfMethodName | VersionFuncWithNoArgs | VersionFuncWithArgs | VersionFuncReturnValue
754 | PackagesHandlerValue = SelfMethodName | PackagesFuncWithNoArgs | PackagesFuncWithArgs | PackagesFuncReturnValue
755 | InstallHandlerValue = SelfMethodName | InstallFuncWithNoArgs | InstallFuncWithArgs | InstallFuncReturnValue
756 |
757 | HandlerType = Literal['abspath', 'version', 'packages', 'install']
758 | HandlerValue = AbspathHandlerValue | VersionHandlerValue | PackagesHandlerValue | InstallHandlerValue
759 | HandlerReturnValue = AbspathFuncReturnValue | VersionFuncReturnValue | PackagesFuncReturnValue | InstallFuncReturnValue
760 |
761 | class HandlerDict(TypedDict, total=False):
762 | abspath: AbspathHandlerValue
763 | version: VersionHandlerValue
764 | packages: PackagesHandlerValue
765 | install: InstallHandlerValue
766 |
767 | # Binary.overrides map BinProviderName:HandlerType:Handler {'brew': {'packages': [...]}}
768 | BinaryOverrides = Dict[BinProviderName, HandlerDict]
769 |
770 | # BinProvider.overrides map BinName:HandlerType:Handler {'wget': {'packages': [...]}}
771 | BinProviderOverrides = Dict[BinName | Literal['*'], HandlerDict]
772 |
773 |
--------------------------------------------------------------------------------
/abx_pkg/binprovider_ansible.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | __package__ = 'abx_pkg'
3 |
4 | import os
5 | import sys
6 | import tempfile
7 | from pathlib import Path
8 | from typing import Optional
9 |
10 | from .base_types import BinProviderName, PATHStr, BinName, InstallArgs
11 | from .binprovider import BinProvider, OPERATING_SYSTEM, DEFAULT_PATH
12 |
13 |
14 | ANSIBLE_INSTALLED = False
15 | ANSIBLE_IMPORT_ERROR = None
16 | try:
17 | from ansible_runner import Runner, RunnerConfig
18 | ANSIBLE_INSTALLED = True
19 | except ImportError as err:
20 | ANSIBLE_IMPORT_ERROR = err
21 |
22 |
23 | ANSIBLE_INSTALL_PLAYBOOK_TEMPLATE = """
24 | ---
25 | - name: Install system packages
26 | hosts: localhost
27 | gather_facts: false
28 | tasks:
29 | - name: 'Install system packages: {pkg_names}'
30 | {installer_module}:
31 | name: "{{{{item}}}}"
32 | state: {state}
33 | loop: {pkg_names}
34 | """
35 |
36 |
37 | def ansible_package_install(pkg_names: str | InstallArgs, playbook_template=ANSIBLE_INSTALL_PLAYBOOK_TEMPLATE, installer_module='auto', state='present', quiet=True) -> str:
38 | if not ANSIBLE_INSTALLED:
39 | raise RuntimeError("Ansible is not installed! To fix:\n pip install ansible ansible-runner") from ANSIBLE_IMPORT_ERROR
40 |
41 | if isinstance(pkg_names, str):
42 | pkg_names = pkg_names.split(' ')
43 | else:
44 | pkg_names = list(pkg_names)
45 |
46 | if installer_module == "auto":
47 | if OPERATING_SYSTEM == 'darwin':
48 | # macOS: Use homebrew
49 | playbook = playbook_template.format(pkg_names=pkg_names, state=state, installer_module="community.general.homebrew")
50 | else:
51 | # Linux: Use Ansible catchall that autodetects apt/yum/pkg/nix/etc.
52 | playbook = playbook_template.format(pkg_names=pkg_names, state=state, installer_module="ansible.builtin.package")
53 | else:
54 | # Custom installer module
55 | playbook = playbook_template.format(pkg_names=pkg_names, state=state, installer_module="ansible.builtin.package")
56 |
57 |
58 | # create a temporary directory using the context manager
59 | with tempfile.TemporaryDirectory() as temp_dir:
60 | ansible_home = Path(temp_dir) / 'tmp'
61 | ansible_home.mkdir(exist_ok=True)
62 |
63 | playbook_path = Path(temp_dir) / 'install_playbook.yml'
64 | playbook_path.write_text(playbook)
65 |
66 | # run the playbook using ansible-runner
67 | os.environ["ANSIBLE_INVENTORY_UNPARSED_WARNING"] = "False"
68 | os.environ["ANSIBLE_LOCALHOST_WARNING"] = "False"
69 | os.environ["ANSIBLE_HOME"] = str(ansible_home)
70 | rc = RunnerConfig(
71 | private_data_dir=temp_dir,
72 | playbook=str(playbook_path),
73 | rotate_artifacts=50000,
74 | host_pattern="localhost",
75 | quiet=quiet,
76 | )
77 | rc.prepare()
78 | r = Runner(config=rc)
79 | r.run()
80 | succeeded = r.status == "successful"
81 | result_text = f'Installing {pkg_names} on {OPERATING_SYSTEM} using Ansible {installer_module} {["failed", "succeeded"][succeeded]}:{r.stdout.read()}\n{r.stderr.read()}'.strip()
82 |
83 | # check for succes/failure
84 | if succeeded:
85 | return result_text
86 | else:
87 | if "Permission denied" in result_text:
88 | raise PermissionError(
89 | f"Installing {pkg_names} failed! Need to be root to use package manager (retry with sudo, or install manually)"
90 | )
91 | raise Exception(f"Installing {pkg_names} failed! (retry with sudo, or install manually)\n{result_text}")
92 |
93 |
94 | class AnsibleProvider(BinProvider):
95 | name: BinProviderName = "ansible"
96 | INSTALLER_BIN: BinName = "ansible"
97 | PATH: PATHStr = os.environ.get("PATH", DEFAULT_PATH)
98 |
99 | ansible_installer_module: str = 'auto' # e.g. community.general.homebrew, ansible.builtin.apt, etc.
100 | ansible_playbook_template: str = ANSIBLE_INSTALL_PLAYBOOK_TEMPLATE
101 |
102 |
103 | def default_install_handler(self, bin_name: str, packages: Optional[InstallArgs] = None, **context) -> str:
104 | packages = packages or self.get_packages(bin_name)
105 |
106 | if not self.INSTALLER_BIN_ABSPATH:
107 | raise Exception(f"{self.__class__.__name__}.INSTALLER_BIN is not available on this host: {self.INSTALLER_BIN}")
108 |
109 | return ansible_package_install(
110 | pkg_names=packages,
111 | quiet=True,
112 | playbook_template=self.ansible_playbook_template,
113 | installer_module=self.ansible_installer_module,
114 | )
115 |
116 |
117 | if __name__ == "__main__":
118 | result = ansible = AnsibleProvider()
119 |
120 | if len(sys.argv) > 1:
121 | result = func = getattr(ansible, sys.argv[1]) # e.g. install
122 |
123 | if len(sys.argv) > 2:
124 | result = func(sys.argv[2]) # e.g. install ffmpeg
125 |
126 | print(result)
127 |
--------------------------------------------------------------------------------
/abx_pkg/binprovider_apt.py:
--------------------------------------------------------------------------------
1 |
2 | #!/usr/bin/env python
3 | __package__ = "abx_pkg"
4 |
5 | import sys
6 | import time
7 | import shutil
8 | from typing import Optional
9 |
10 | from pydantic import model_validator, TypeAdapter
11 |
12 | from .base_types import BinProviderName, PATHStr, BinName, InstallArgs
13 | from .binprovider import BinProvider
14 |
15 | _LAST_UPDATE_CHECK = None
16 | UPDATE_CHECK_INTERVAL = 60 * 60 * 24 # 1 day
17 |
18 |
19 | class AptProvider(BinProvider):
20 | name: BinProviderName = "apt"
21 | INSTALLER_BIN: BinName = "apt-get"
22 |
23 | PATH: PATHStr = ""
24 |
25 | euid: Optional[int] = 0 # always run apt as root
26 |
27 | @model_validator(mode="after")
28 | def load_PATH_from_dpkg_install_location(self):
29 | dpkg_abspath = shutil.which("dpkg")
30 | if (not self.INSTALLER_BIN_ABSPATH) or not dpkg_abspath or not self.is_valid:
31 | # package manager is not available on this host
32 | # self.PATH: PATHStr = ''
33 | # self.INSTALLER_BIN_ABSPATH = None
34 | return self
35 |
36 | PATH = self.PATH
37 | dpkg_install_dirs = self.exec(bin_name=dpkg_abspath, cmd=["-L", "bash"], quiet=True).stdout.strip().split("\n")
38 | dpkg_bin_dirs = [path for path in dpkg_install_dirs if path.endswith("/bin")]
39 | for bin_dir in dpkg_bin_dirs:
40 | if str(bin_dir) not in PATH:
41 | PATH = ":".join([str(bin_dir), *PATH.split(":")])
42 | self.PATH = TypeAdapter(PATHStr).validate_python(PATH)
43 | return self
44 |
45 | def default_install_handler(self, bin_name: BinName, packages: Optional[InstallArgs] = None, **context) -> str:
46 | global _LAST_UPDATE_CHECK
47 |
48 | packages = packages or self.get_packages(bin_name)
49 |
50 | if not (self.INSTALLER_BIN_ABSPATH and shutil.which("dpkg")):
51 | raise Exception(f"{self.__class__.__name__}.INSTALLER_BIN is not available on this host: {self.INSTALLER_BIN}")
52 |
53 | # print(f'[*] {self.__class__.__name__}: Installing {bin_name}: {self.INSTALLER_BIN} install {packages}')
54 |
55 | # Attempt 1: Try installing with Pyinfra
56 | from .binprovider_pyinfra import PYINFRA_INSTALLED, pyinfra_package_install
57 |
58 | if PYINFRA_INSTALLED:
59 | return pyinfra_package_install([bin_name], installer_module="operations.apt.packages")
60 |
61 | # Attempt 2: Try installing with Ansible
62 | from .binprovider_ansible import ANSIBLE_INSTALLED, ansible_package_install
63 |
64 | if ANSIBLE_INSTALLED:
65 | return ansible_package_install([bin_name], installer_module="ansible.builtin.apt")
66 |
67 | # Attempt 3: Fallback to installing manually by calling apt in shell
68 | if not _LAST_UPDATE_CHECK or (time.time() - _LAST_UPDATE_CHECK) > UPDATE_CHECK_INTERVAL:
69 | # only update if we haven't checked in the last day
70 | self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=["update", "-qq"])
71 | _LAST_UPDATE_CHECK = time.time()
72 |
73 | proc = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=["install", "-y", "-qq", "--no-install-recommends", *packages])
74 | if proc.returncode != 0:
75 | print(proc.stdout.strip())
76 | print(proc.stderr.strip())
77 | raise Exception(f"{self.__class__.__name__} install got returncode {proc.returncode} while installing {packages}: {packages}")
78 |
79 | return proc.stderr.strip() + "\n" + proc.stdout.strip()
80 | return f"Installed {packages} succesfully."
81 |
82 |
83 | if __name__ == "__main__":
84 | result = apt = AptProvider()
85 |
86 | if len(sys.argv) > 1:
87 | result = func = getattr(apt, sys.argv[1]) # e.g. install
88 |
89 | if len(sys.argv) > 2:
90 | result = func(sys.argv[2]) # e.g. install ffmpeg
91 |
92 | print(result)
93 |
--------------------------------------------------------------------------------
/abx_pkg/binprovider_brew.py:
--------------------------------------------------------------------------------
1 |
2 | #!/usr/bin/env python3
3 | __package__ = "abx_pkg"
4 |
5 | import os
6 | import sys
7 | import time
8 | import platform
9 | from typing import Optional
10 | from pathlib import Path
11 |
12 | from pydantic import model_validator, TypeAdapter
13 |
14 | from .base_types import BinProviderName, PATHStr, BinName, InstallArgs, HostBinPath, bin_abspath
15 | from .semver import SemVer
16 | from .binprovider import BinProvider
17 |
18 | OS = platform.system().lower()
19 |
20 | NEW_MACOS_DIR = Path('/opt/homebrew/bin')
21 | OLD_MACOS_DIR = Path('/usr/local/bin')
22 | DEFAULT_MACOS_DIR = NEW_MACOS_DIR if platform.machine() == 'arm64' else OLD_MACOS_DIR
23 | DEFAULT_LINUX_DIR = Path('/home/linuxbrew/.linuxbrew/bin')
24 | GUESSED_BREW_PREFIX = DEFAULT_MACOS_DIR if OS == 'darwin' else DEFAULT_LINUX_DIR
25 |
26 | _LAST_UPDATE_CHECK = None
27 | UPDATE_CHECK_INTERVAL = 60 * 60 * 24 # 1 day
28 |
29 |
30 | class BrewProvider(BinProvider):
31 | name: BinProviderName = "brew"
32 | INSTALLER_BIN: BinName = "brew"
33 |
34 | PATH: PATHStr = f"{DEFAULT_LINUX_DIR}:{NEW_MACOS_DIR}:{OLD_MACOS_DIR}"
35 |
36 | brew_prefix: Path = GUESSED_BREW_PREFIX
37 |
38 | @model_validator(mode="after")
39 | def load_PATH(self):
40 | if not self.INSTALLER_BIN_ABSPATH:
41 | # brew is not availabe on this host
42 | self.PATH: PATHStr = ""
43 | return self
44 |
45 | PATHs = set(self.PATH.split(':'))
46 |
47 | if OS == 'darwin' and os.path.isdir(DEFAULT_MACOS_DIR) and os.access(DEFAULT_MACOS_DIR, os.R_OK):
48 | PATHs.add(str(DEFAULT_MACOS_DIR))
49 | self.brew_prefix = DEFAULT_MACOS_DIR / "bin"
50 | if OS != 'darwin' and os.path.isdir(DEFAULT_LINUX_DIR) and os.access(DEFAULT_LINUX_DIR, os.R_OK):
51 | PATHs.add(str(DEFAULT_LINUX_DIR))
52 | self.brew_prefix = DEFAULT_LINUX_DIR / "bin"
53 |
54 | if not PATHs:
55 | # if we cant autodetect the paths, run brew --prefix to get the path manually (very slow)
56 | self.brew_prefix = Path(self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=["--prefix"]).stdout.strip())
57 | PATHs.add(str(self.brew_prefix / "bin"))
58 |
59 | self.PATH = TypeAdapter(PATHStr).validate_python(':'.join(PATHs))
60 | return self
61 |
62 | def default_install_handler(self, bin_name: str, packages: Optional[InstallArgs] = None, **context) -> str:
63 | global _LAST_UPDATE_CHECK
64 |
65 | packages = packages or self.get_packages(bin_name)
66 |
67 | if not self.INSTALLER_BIN_ABSPATH:
68 | raise Exception(f"{self.__class__.__name__}.INSTALLER_BIN is not available on this host: {self.INSTALLER_BIN}")
69 |
70 | # print(f'[*] {self.__class__.__name__}: Installing {bin_name}: {self.INSTALLER_BIN_ABSPATH} install {packages}')
71 |
72 | # Attempt 1: Try installing with Pyinfra
73 | from .binprovider_pyinfra import PYINFRA_INSTALLED, pyinfra_package_install
74 |
75 | if PYINFRA_INSTALLED:
76 | return pyinfra_package_install((bin_name,), installer_module="operations.brew.packages")
77 |
78 | # Attempt 2: Try installing with Ansible
79 | from .binprovider_ansible import ANSIBLE_INSTALLED, ansible_package_install
80 |
81 | if ANSIBLE_INSTALLED:
82 | return ansible_package_install(bin_name, installer_module="community.general.homebrew")
83 |
84 | # Attempt 3: Fallback to installing manually by calling brew in shell
85 |
86 | if not _LAST_UPDATE_CHECK or (time.time() - _LAST_UPDATE_CHECK) > UPDATE_CHECK_INTERVAL:
87 | # only update if we haven't checked in the last day
88 | self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=["update"])
89 | _LAST_UPDATE_CHECK = time.time()
90 |
91 | proc = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=["install", *packages])
92 | if proc.returncode != 0:
93 | print(proc.stdout.strip())
94 | print(proc.stderr.strip())
95 | raise Exception(f"{self.__class__.__name__} install got returncode {proc.returncode} while installing {packages}: {packages}")
96 |
97 | return proc.stderr.strip() + "\n" + proc.stdout.strip()
98 |
99 | def default_abspath_handler(self, bin_name: BinName | HostBinPath, **context) -> HostBinPath | None:
100 | # print(f'[*] {self.__class__.__name__}: Getting abspath for {bin_name}...')
101 |
102 | if not self.PATH:
103 | return None
104 |
105 | # not all brew-installed binaries are symlinked into the default bin dir (e.g. curl)
106 | # because it might conflict with a system binary of the same name (e.g. /usr/bin/curl)
107 | # so we need to check for the binary in the namespaced opt dir and Cellar paths as well
108 | extra_path = self.PATH.replace('/bin', f'/opt/{bin_name}/bin') # e.g. /opt/homebrew/opt/curl/bin/curl
109 | search_paths = f'{self.PATH}:{extra_path}'
110 |
111 | # add unlinked Cellar paths,e.g. /opt/homebrew/Cellar/curl/8.10.1/bin
112 | cellar_paths = ':'.join(str(path) for path in (self.brew_prefix / 'Cellar' / bin_name).glob('*/bin'))
113 | if cellar_paths:
114 | search_paths += ':' + cellar_paths
115 |
116 | abspath = bin_abspath(bin_name, PATH=search_paths)
117 | if abspath:
118 | return abspath
119 |
120 | if not self.INSTALLER_BIN_ABSPATH:
121 | return None
122 |
123 | # This code works but theres no need, the method above is much faster:
124 |
125 | # # try checking filesystem or using brew list to get the Cellar bin path (faster than brew info)
126 | # for package in (self.get_packages(str(bin_name)) or [str(bin_name)]):
127 | # try:
128 | # paths = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=[
129 | # 'list',
130 | # '--formulae',
131 | # package,
132 | # ], timeout=self._version_timeout, quiet=True).stdout.strip().split('\n')
133 | # # /opt/homebrew/Cellar/curl/8.10.1/bin/curl
134 | # # /opt/homebrew/Cellar/curl/8.10.1/bin/curl-config
135 | # # /opt/homebrew/Cellar/curl/8.10.1/include/curl/ (12 files)
136 | # return [line for line in paths if '/Cellar/' in line and line.endswith(f'/bin/{bin_name}')][0].strip()
137 | # except Exception:
138 | # pass
139 |
140 | # # fallback to using brew info to get the Cellar bin path
141 | # for package in (self.get_packages(str(bin_name)) or [str(bin_name)]):
142 | # try:
143 | # info_lines = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=[
144 | # 'info',
145 | # '--quiet',
146 | # package,
147 | # ], timeout=self._version_timeout, quiet=True).stdout.strip().split('\n')
148 | # # /opt/homebrew/Cellar/curl/8.10.0 (530 files, 4MB)
149 | # cellar_path = [line for line in info_lines if '/Cellar/' in line][0].rsplit(' (', 1)[0]
150 | # abspath = bin_abspath(bin_name, PATH=f'{cellar_path}/bin')
151 | # if abspath:
152 | # return abspath
153 | # except Exception:
154 | # pass
155 | # return None
156 |
157 |
158 | def default_version_handler(self, bin_name: BinName, abspath: Optional[HostBinPath]=None, **context) -> SemVer | None:
159 | # print(f'[*] {self.__class__.__name__}: Getting version for {bin_name}...')
160 |
161 | # shortcut: if we already have the Cellar abspath, extract the version from it
162 | if abspath and '/Cellar/' in str(abspath):
163 | # /opt/homebrew/Cellar/curl/8.10.1/bin/curl -> 8.10.1
164 | version = str(abspath).rsplit(f'/bin/{bin_name}', 1)[0].rsplit('/', 1)[-1]
165 | if version:
166 | try:
167 | return SemVer.parse(version)
168 | except ValueError:
169 | pass
170 |
171 | # fallback to running $ --version
172 | try:
173 | version = super().default_version_handler(bin_name, abspath=abspath, **context)
174 | if version:
175 | return SemVer.parse(version)
176 | except ValueError:
177 | pass
178 |
179 | if not self.INSTALLER_BIN_ABSPATH:
180 | return None
181 |
182 | # fallback to using brew list to get the package version (faster than brew info)
183 | for package in (self.get_packages(str(bin_name)) or [str(bin_name)]):
184 | try:
185 | paths = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=[
186 | 'list',
187 | '--formulae',
188 | package,
189 | ], timeout=self._version_timeout, quiet=True).stdout.strip().split('\n')
190 | # /opt/homebrew/Cellar/curl/8.10.1/bin/curl
191 | cellar_abspath = [line for line in paths if '/Cellar/' in line and line.endswith(f'/bin/{bin_name}')][0].strip()
192 | # /opt/homebrew/Cellar/curl/8.10.1/bin/curl -> 8.10.1
193 | version = cellar_abspath.rsplit(f'/bin/{bin_name}', 1)[0].rsplit('/', 1)[-1]
194 | if version:
195 | return SemVer.parse(version)
196 | except Exception:
197 | pass
198 |
199 | # fallback to using brew info to get the version (slowest method of all)
200 | packages = self.get_packages(str(bin_name)) or [str(bin_name)]
201 | main_package = packages[0] # assume first package in list is the main one
202 | try:
203 | version_str = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=[
204 | 'info',
205 | '--quiet',
206 | main_package,
207 | ], quiet=True, timeout=self._version_timeout).stdout.strip().split('\n')[0]
208 | # ==> curl: stable 8.10.1 (bottled), HEAD [keg-only]
209 | return SemVer.parse(version_str)
210 | except Exception:
211 | return None
212 |
213 | return None
214 |
215 | if __name__ == "__main__":
216 | # Usage:
217 | # ./binprovider_brew.py load yt-dlp
218 | # ./binprovider_brew.py install pip
219 | # ./binprovider_brew.py get_version pip
220 | # ./binprovider_brew.py get_abspath pip
221 | result = brew = BrewProvider()
222 |
223 | if len(sys.argv) > 1:
224 | result = func = getattr(brew, sys.argv[1]) # e.g. install
225 |
226 | if len(sys.argv) > 2:
227 | result = func(sys.argv[2]) # e.g. install ffmpeg
228 |
229 | print(result)
230 |
--------------------------------------------------------------------------------
/abx_pkg/binprovider_npm.py:
--------------------------------------------------------------------------------
1 |
2 | #!/usr/bin/env python3
3 |
4 | __package__ = "abx_pkg"
5 |
6 | import os
7 | import sys
8 | import json
9 | import tempfile
10 |
11 | from pathlib import Path
12 | from typing import Optional, List
13 | from typing_extensions import Self
14 |
15 | from pydantic import model_validator, TypeAdapter, computed_field
16 | from platformdirs import user_cache_path
17 |
18 | from .base_types import BinProviderName, PATHStr, BinName, InstallArgs, HostBinPath, bin_abspath
19 | from .semver import SemVer
20 | from .binprovider import BinProvider
21 |
22 | # Cache these values globally because they never change at runtime
23 | _CACHED_GLOBAL_NPM_PREFIX: Path | None = None
24 | _CACHED_HOME_DIR: Path = Path('~').expanduser().absolute()
25 |
26 |
27 | USER_CACHE_PATH = Path(tempfile.gettempdir()) / 'npm-cache'
28 | try:
29 | user_cache_path = user_cache_path(appname='npm', appauthor='abx-pkg', ensure_exists=True)
30 | if os.access(user_cache_path, os.W_OK):
31 | USER_CACHE_PATH = user_cache_path
32 | except Exception:
33 | pass
34 |
35 |
36 | class NpmProvider(BinProvider):
37 | name: BinProviderName = 'npm'
38 | INSTALLER_BIN: BinName = 'npm'
39 |
40 | PATH: PATHStr = ''
41 |
42 | npm_prefix: Optional[Path] = None # None = -g global, otherwise it's a path
43 |
44 | cache_dir: Path = USER_CACHE_PATH
45 | cache_arg: str = f'--cache={cache_dir}'
46 |
47 | npm_install_args: List[str] = ['--force', '--no-audit', '--no-fund', '--loglevel=error']
48 |
49 | _CACHED_LOCAL_NPM_PREFIX: Path | None = None
50 |
51 | @computed_field
52 | @property
53 | def is_valid(self) -> bool:
54 | """False if npm_prefix is not created yet or if npm binary is not found in PATH"""
55 | if self.npm_prefix:
56 | npm_bin_dir = self.npm_prefix / 'node_modules' / '.bin'
57 | npm_bin_dir_exists = (os.path.isdir(npm_bin_dir) and os.access(npm_bin_dir, os.R_OK))
58 | if not npm_bin_dir_exists:
59 | return False
60 |
61 | return bool(self.INSTALLER_BIN_ABSPATH)
62 |
63 | @model_validator(mode='after')
64 | def detect_euid_to_use(self) -> Self:
65 | """Detect the user (UID) to run as when executing npm (should be same as the user that owns the npm_prefix dir)"""
66 | if self.euid is None:
67 | # try dropping to the owner of the npm prefix dir if it exists
68 | if self.npm_prefix and os.path.isdir(self.npm_prefix):
69 | self.euid = os.stat(self.npm_prefix).st_uid
70 |
71 | # try dropping to the owner of the npm binary if it's not root
72 | installer_bin = self.INSTALLER_BIN_ABSPATH
73 | if installer_bin:
74 | self.euid = self.euid or os.stat(installer_bin).st_uid
75 |
76 | # fallback to the currently running user
77 | self.euid = self.euid or os.geteuid()
78 |
79 | return self
80 |
81 | @model_validator(mode='after')
82 | def load_PATH_from_npm_prefix(self) -> Self:
83 | self.PATH = self._load_PATH()
84 | return self
85 |
86 | def _load_PATH(self) -> str:
87 | PATH = self.PATH
88 | npm_bin_dirs: set[Path] = set()
89 | global _CACHED_GLOBAL_NPM_PREFIX
90 |
91 | if self.npm_prefix:
92 | # restrict PATH to only use npm prefix
93 | npm_bin_dirs = {self.npm_prefix / 'node_modules/.bin'}
94 |
95 | if self.INSTALLER_BIN_ABSPATH:
96 | # find all local and global npm PATHs
97 | npm_local_dir = self._CACHED_LOCAL_NPM_PREFIX or self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=['prefix'], quiet=True).stdout.strip()
98 | self._CACHED_LOCAL_NPM_PREFIX = npm_local_dir
99 |
100 | # start at npm_local_dir and walk up to $HOME (or /), finding all npm bin dirs along the way
101 | search_dir = Path(npm_local_dir)
102 | stop_if_reached = [str(Path('/')), str(_CACHED_HOME_DIR)]
103 | num_hops, max_hops = 0, 6
104 | while num_hops < max_hops and str(search_dir) not in stop_if_reached:
105 | try:
106 | npm_bin_dirs.add(list(search_dir.glob('node_modules/.bin'))[0])
107 | break
108 | except (IndexError, OSError, Exception):
109 | # could happen becuase we dont have permission to access the parent dir, or it's been moved, or many other weird edge cases...
110 | pass
111 | search_dir = search_dir.parent
112 | num_hops += 1
113 |
114 | npm_global_dir = _CACHED_GLOBAL_NPM_PREFIX or self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=['prefix', '-g'], quiet=True).stdout.strip() + '/bin' # /opt/homebrew/bin
115 | _CACHED_GLOBAL_NPM_PREFIX = npm_global_dir
116 | npm_bin_dirs.add(npm_global_dir)
117 |
118 | for bin_dir in npm_bin_dirs:
119 | if str(bin_dir) not in PATH:
120 | PATH = ':'.join([*PATH.split(':'), str(bin_dir)])
121 | return TypeAdapter(PATHStr).validate_python(PATH)
122 |
123 | def setup(self) -> None:
124 | """create npm install prefix and node_modules_dir if needed"""
125 | if not self.PATH or not self._CACHED_LOCAL_NPM_PREFIX:
126 | self.PATH = self._load_PATH()
127 |
128 | try:
129 | self.cache_dir.mkdir(parents=True, exist_ok=True)
130 | os.system(f'chown {self.EUID} "{self.cache_dir}"')
131 | os.system(f'chmod 777 "{self.cache_dir}"') # allow all users to share cache dir
132 | except Exception:
133 | self.cache_arg = '--no-cache'
134 |
135 | if self.npm_prefix:
136 | (self.npm_prefix / 'node_modules/.bin').mkdir(parents=True, exist_ok=True)
137 |
138 | def default_install_handler(self, bin_name: str, packages: Optional[InstallArgs]=None, **context) -> str:
139 | self.setup()
140 |
141 | packages = packages or self.get_packages(bin_name)
142 | if not self.INSTALLER_BIN_ABSPATH:
143 | raise Exception(f'{self.__class__.__name__} install method is not available on this host ({self.INSTALLER_BIN} not found in $PATH)')
144 |
145 | # print(f'[*] {self.__class__.__name__}: Installing {bin_name}: {self.INSTALLER_BIN_ABSPATH} install {packages}')
146 |
147 | install_args = [*self.npm_install_args, self.cache_arg]
148 | if self.npm_prefix:
149 | install_args.append(f'--prefix={self.npm_prefix}')
150 | else:
151 | install_args.append('--global')
152 |
153 | proc = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=[
154 | "install",
155 | *install_args,
156 | *packages,
157 | ])
158 |
159 | if proc.returncode != 0:
160 | print(proc.stdout.strip())
161 | print(proc.stderr.strip())
162 | raise Exception(f'{self.__class__.__name__}: install got returncode {proc.returncode} while installing {packages}: {packages}')
163 |
164 | return (proc.stderr.strip() + '\n' + proc.stdout.strip()).strip()
165 |
166 | def default_abspath_handler(self, bin_name: BinName, **context) -> HostBinPath | None:
167 | # print(self.__class__.__name__, 'on_get_abspath', bin_name)
168 |
169 | # try searching for the bin_name in BinProvider.PATH first (fastest)
170 | try:
171 | abspath = super().default_abspath_handler(bin_name, **context)
172 | if abspath:
173 | return TypeAdapter(HostBinPath).validate_python(abspath)
174 | except Exception:
175 | pass
176 |
177 | if not self.INSTALLER_BIN_ABSPATH:
178 | return None
179 |
180 | # fallback to using npm show to get alternate binary names based on the package, then try to find those in BinProvider.PATH
181 | try:
182 | packages = self.get_packages(str(bin_name)) or [str(bin_name)]
183 | main_package = packages[0] # assume first package in list is the main one
184 | output_lines = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=[
185 | 'show',
186 | '--json',
187 | main_package,
188 | ], timeout=self._version_timeout, quiet=True).stdout.strip().split('\n')
189 | # { ...
190 | # "version": "2.2.3",
191 | # "bin": {
192 | # "mercury-parser": "cli.js",
193 | # "postlight-parser": "cli.js"
194 | # },
195 | # ...
196 | # }
197 | alt_bin_names = json.loads(output_lines[0])['bin'].keys()
198 | for alt_bin_name in alt_bin_names:
199 | abspath = bin_abspath(alt_bin_name, PATH=self.PATH)
200 | if abspath:
201 | return TypeAdapter(HostBinPath).validate_python(abspath)
202 | except Exception:
203 | pass
204 | return None
205 |
206 | def default_version_handler(self, bin_name: BinName, abspath: Optional[HostBinPath]=None, **context) -> SemVer | None:
207 | # print(f'[*] {self.__class__.__name__}: Getting version for {bin_name}...')
208 | try:
209 | version = super().default_version_handler(bin_name, abspath, **context)
210 | if version:
211 | return SemVer.parse(version)
212 | except ValueError:
213 | pass
214 |
215 | if not self.INSTALLER_BIN_ABSPATH:
216 | return None
217 |
218 | # fallback to using npm list to get the installed package version
219 | try:
220 | packages = self.get_packages(str(bin_name), **context) or [str(bin_name)]
221 | main_package = packages[0] # assume first package in list is the main one
222 |
223 | # remove the package version if it exists "@postslight/parser@^1.2.3" -> "@postlight/parser"
224 | if main_package[0] == '@':
225 | package = '@' + main_package[1:].split('@', 1)[0]
226 | else:
227 | package = main_package.split('@', 1)[0]
228 |
229 | # npm list --depth=0 --json --prefix= "@postlight/parser"
230 | # (dont use 'npm info @postlight/parser version', it shows *any* availabe version, not installed version)
231 | json_output = self.exec(bin_name=self.INSTALLER_BIN_ABSPATH, cmd=[
232 | 'list',
233 | f'--prefix={self.npm_prefix}' if self.npm_prefix else '--global',
234 | '--depth=0',
235 | '--json',
236 | package,
237 | ], timeout=self._version_timeout, quiet=True).stdout.strip()
238 | # {
239 | # "name": "lib",
240 | # "dependencies": {
241 | # "@postlight/parser": {
242 | # "version": "2.2.3",
243 | # "overridden": false
244 | # }
245 | # }
246 | # }
247 | version_str = json.loads(json_output)['dependencies'][package]['version']
248 | return SemVer.parse(version_str)
249 | except Exception:
250 | raise
251 | return None
252 |
253 | if __name__ == "__main__":
254 | # Usage:
255 | # ./binprovider_npm.py load @postlight/parser
256 | # ./binprovider_npm.py install @postlight/parser
257 | # ./binprovider_npm.py get_version @postlight/parser
258 | # ./binprovider_npm.py get_abspath @postlight/parser
259 | result = npm = NpmProvider()
260 |
261 | if len(sys.argv) > 1:
262 | result = func = getattr(npm, sys.argv[1]) # e.g. install
263 |
264 | if len(sys.argv) > 2:
265 | result = func(sys.argv[2]) # e.g. install ffmpeg
266 |
267 | print(result)
268 |
--------------------------------------------------------------------------------
/abx_pkg/binprovider_pip.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | __package__ = "abx_pkg"
4 |
5 | import os
6 | import sys
7 | import site
8 | import shutil
9 | import sysconfig
10 | import subprocess
11 | import tempfile
12 | from platformdirs import user_cache_path
13 |
14 | from pathlib import Path
15 | from typing import Optional, List, Set
16 | from typing_extensions import Self
17 | from pydantic import model_validator, TypeAdapter, computed_field
18 |
19 | from .base_types import BinProviderName, PATHStr, BinName, InstallArgs, HostBinPath, bin_abspath, bin_abspaths
20 | from .semver import SemVer
21 | from .binprovider import BinProvider, DEFAULT_ENV_PATH
22 |
23 | ACTIVE_VENV = os.getenv('VIRTUAL_ENV', None)
24 | _CACHED_GLOBAL_PIP_BIN_DIRS: Set[str] | None = None
25 |
26 |
27 | USER_CACHE_PATH = Path(tempfile.gettempdir()) / 'pip-cache'
28 | try:
29 | user_cache_path = user_cache_path(appname='pip', appauthor='abx-pkg', ensure_exists=True)
30 | if os.access(user_cache_path, os.W_OK):
31 | USER_CACHE_PATH = user_cache_path
32 | except Exception:
33 | pass
34 |
35 |
36 | class PipProvider(BinProvider):
37 | name: BinProviderName = "pip"
38 | INSTALLER_BIN: BinName = "pip"
39 |
40 | PATH: PATHStr = ''
41 |
42 | pip_venv: Optional[Path] = None # None = system site-packages (user or global), otherwise it's a path e.g. DATA_DIR/lib/pip/venv
43 |
44 | cache_dir: Path = USER_CACHE_PATH
45 | cache_arg: str = f'--cache-dir={cache_dir}'
46 |
47 | pip_install_args: List[str] = ["--no-input", "--disable-pip-version-check", "--quiet"] # extra args for pip install ... e.g. --upgrade
48 |
49 | _INSTALLER_BIN_ABSPATH: HostBinPath | None = None # speed optimization only, faster to cache the abspath than to recompute it on every access
50 |
51 | @computed_field
52 | @property
53 | def is_valid(self) -> bool:
54 | """False if pip_venv is not created yet or if pip binary is not found in PATH"""
55 | if self.pip_venv:
56 | venv_pip_path = self.pip_venv / "bin" / "python"
57 | venv_pip_binary_exists = (os.path.isfile(venv_pip_path) and os.access(venv_pip_path, os.X_OK))
58 | if not venv_pip_binary_exists:
59 | return False
60 |
61 | return bool(self.INSTALLER_BIN_ABSPATH)
62 |
63 | @computed_field
64 | @property
65 | def INSTALLER_BIN_ABSPATH(self) -> HostBinPath | None:
66 | """Actual absolute path of the underlying package manager (e.g. /usr/local/bin/npm)"""
67 | if self._INSTALLER_BIN_ABSPATH:
68 | # return cached value if we have one
69 | return self._INSTALLER_BIN_ABSPATH
70 |
71 | abspath = None
72 |
73 | if self.pip_venv:
74 | assert self.INSTALLER_BIN != 'pipx', "Cannot use pipx with pip_venv"
75 |
76 | # use venv pip
77 | venv_pip_path = self.pip_venv / "bin" / self.INSTALLER_BIN
78 | if os.path.isfile(venv_pip_path) and os.access(venv_pip_path, os.R_OK) and os.access(venv_pip_path, os.X_OK):
79 | abspath = str(venv_pip_path)
80 | else:
81 | # use system pip
82 | relpath = bin_abspath(self.INSTALLER_BIN, PATH=DEFAULT_ENV_PATH) or shutil.which(self.INSTALLER_BIN)
83 | abspath = relpath and Path(relpath).resolve() # find self.INSTALLER_BIN abspath using environment path
84 |
85 | if not abspath:
86 | # underlying package manager not found on this host, return None
87 | return None
88 | valid_abspath = TypeAdapter(HostBinPath).validate_python(abspath)
89 | if valid_abspath:
90 | # if we found a valid abspath, cache it
91 | self._INSTALLER_BIN_ABSPATH = valid_abspath
92 | return valid_abspath
93 |
94 | @model_validator(mode='after')
95 | def detect_euid_to_use(self) -> Self:
96 | """Detect the user (UID) to run as when executing pip (should be same as the user that owns the pip_venv dir)"""
97 |
98 | if self.euid is None:
99 | # try dropping to the owner of the npm prefix dir if it exists
100 | if self.pip_venv and os.path.isdir(self.pip_venv):
101 | self.euid = os.stat(self.pip_venv).st_uid
102 |
103 | # try dropping to the owner of the npm binary if it's not root
104 | installer_bin = self.INSTALLER_BIN_ABSPATH
105 | if installer_bin:
106 | self.euid = self.euid or os.stat(installer_bin).st_uid
107 |
108 | # fallback to the currently running user
109 | self.euid = self.euid or os.geteuid()
110 |
111 | return self
112 |
113 | @model_validator(mode="after")
114 | def load_PATH_from_pip_sitepackages(self) -> Self:
115 | """Assemble PATH from pip_venv, pipx, or autodetected global python system site-packages and user site-packages"""
116 | global _CACHED_GLOBAL_PIP_BIN_DIRS
117 | PATH = self.PATH
118 |
119 | pip_bin_dirs = set()
120 |
121 | if self.pip_venv:
122 | # restrict PATH to only use venv bin path
123 | pip_bin_dirs = {str(self.pip_venv / "bin")}
124 |
125 | elif self.INSTALLER_BIN == "pipx":
126 | # restrict PATH to only use global pipx bin path
127 | pipx_abspath = self.INSTALLER_BIN_ABSPATH
128 | if pipx_abspath:
129 | proc = self.exec(bin_name=pipx_abspath, cmd=["environment"], quiet=True, timeout=self._version_timeout) # run $ pipx environment
130 | if proc.returncode == 0:
131 | PIPX_BIN_DIR = proc.stdout.strip().split("PIPX_BIN_DIR=")[-1].split("\n", 1)[0]
132 | pip_bin_dirs = {PIPX_BIN_DIR}
133 | else:
134 | # autodetect global system python paths
135 |
136 | if _CACHED_GLOBAL_PIP_BIN_DIRS:
137 | pip_bin_dirs = _CACHED_GLOBAL_PIP_BIN_DIRS.copy()
138 | else:
139 | pip_bin_dirs = {
140 | * (
141 | str(Path(sitepackage_dir).parent.parent.parent / "bin") # /opt/homebrew/opt/python@3.11/Frameworks/Python.framework/Versions/3.11/bin
142 | for sitepackage_dir in site.getsitepackages()
143 | ),
144 | str(Path(site.getusersitepackages()).parent.parent.parent / "bin"), # /Users/squash/Library/Python/3.9/bin
145 | sysconfig.get_path("scripts"), # /opt/homebrew/bin
146 | str(Path(sys.executable).resolve().parent), # /opt/homebrew/Cellar/python@3.11/3.11.9/Frameworks/Python.framework/Versions/3.11/bin
147 | }
148 |
149 | # find every python installed in the system PATH and add their parent path, as that's where its corresponding pip will link global bins
150 | for abspath in bin_abspaths("python", PATH=DEFAULT_ENV_PATH): # ~/Library/Frameworks/Python.framework/Versions/3.10/bin
151 | pip_bin_dirs.add(str(abspath.parent))
152 | for abspath in bin_abspaths("python3", PATH=DEFAULT_ENV_PATH): # /usr/local/bin or anywhere else we see python3 in $PATH
153 | pip_bin_dirs.add(str(abspath.parent))
154 |
155 | _CACHED_GLOBAL_PIP_BIN_DIRS = pip_bin_dirs.copy()
156 |
157 | # remove any active venv from PATH because we're trying to only get the global system python paths
158 | if ACTIVE_VENV:
159 | pip_bin_dirs.remove(f"{ACTIVE_VENV}/bin")
160 |
161 | for bin_dir in pip_bin_dirs:
162 | if bin_dir not in PATH:
163 | PATH = ":".join([*PATH.split(":"), bin_dir])
164 | self.PATH = TypeAdapter(PATHStr).validate_python(PATH)
165 | return self
166 |
167 | def setup(self):
168 | """create pip venv dir if needed"""
169 | try:
170 | self.cache_dir.mkdir(parents=True, exist_ok=True)
171 | os.system(f'chown {self.EUID} "{self.cache_dir}" 2>/dev/null') # try to ensure cache dir is writable by EUID
172 | os.system(f'chmod 777 "{self.cache_dir}" 2>/dev/null') # allow all users to share cache dir
173 | except Exception:
174 | self.cache_arg = '--no-cache-dir'
175 |
176 | if self.pip_venv:
177 | self._pip_setup_venv(self.pip_venv)
178 |
179 | def _pip_setup_venv(self, pip_venv: Path):
180 | pip_venv.parent.mkdir(parents=True, exist_ok=True)
181 |
182 | # create new venv in pip_venv if it doesnt exist
183 | venv_pip_path = pip_venv / "bin" / "python"
184 | venv_pip_binary_exists = (os.path.isfile(venv_pip_path) and os.access(venv_pip_path, os.X_OK))
185 | if not venv_pip_binary_exists:
186 | import venv
187 |
188 | venv.create(
189 | str(pip_venv),
190 | system_site_packages=False,
191 | clear=True,
192 | symlinks=True,
193 | with_pip=True,
194 | upgrade_deps=True,
195 | )
196 | assert os.path.isfile(venv_pip_path) and os.access(venv_pip_path, os.X_OK), f'could not find pip inside venv after creating it: {pip_venv}'
197 | self.exec(bin_name=venv_pip_path, cmd=["install", self.cache_arg, "--upgrade", "pip", "setuptools"]) # setuptools is not installed by default after python >= 3.12
198 |
199 | def _pip_show(self, bin_name: BinName, packages: Optional[InstallArgs] = None) -> List[str]:
200 | pip_abspath = self.INSTALLER_BIN_ABSPATH
201 | if not pip_abspath:
202 | raise Exception(
203 | f"{self.__class__.__name__} install method is not available on this host ({self.INSTALLER_BIN} not found in $PATH)"
204 | )
205 |
206 | packages = packages or self.get_packages(str(bin_name)) or [str(bin_name)]
207 | main_package = packages[0] # assume first package in list is the main one
208 | output_lines = self.exec(bin_name=pip_abspath, cmd=[
209 | 'show',
210 | '--no-input',
211 | main_package,
212 | ], timeout=self._version_timeout, quiet=True).stdout.strip().split('\n')
213 | return output_lines
214 |
215 | def _pip_install(self, packages: InstallArgs) -> subprocess.CompletedProcess:
216 | pip_abspath = self.INSTALLER_BIN_ABSPATH
217 | if not pip_abspath:
218 | raise Exception(
219 | f"{self.__class__.__name__} install method is not available on this host ({self.INSTALLER_BIN} not found in $PATH)"
220 | )
221 |
222 | return self.exec(bin_name=pip_abspath, cmd=[
223 | 'install',
224 | '--no-input',
225 | self.cache_arg,
226 | *self.pip_install_args,
227 | *packages,
228 | ])
229 |
230 |
231 | def default_install_handler(self, bin_name: str, packages: Optional[InstallArgs] = None, **context) -> str:
232 | if self.pip_venv:
233 | self.setup()
234 |
235 | packages = packages or self.get_packages(bin_name)
236 |
237 | # print(f'[*] {self.__class__.__name__}: Installing {bin_name}: {self.INSTALLER_BIN_ABSPATH} install {packages}')
238 |
239 | # pip install --no-input --cache-dir=
240 | proc = self._pip_install(packages)
241 |
242 | if proc.returncode != 0:
243 | print(proc.stdout.strip())
244 | print(proc.stderr.strip())
245 | raise Exception(f"{self.__class__.__name__}: install got returncode {proc.returncode} while installing {packages}: {packages}")
246 |
247 | return proc.stderr.strip() + "\n" + proc.stdout.strip()
248 |
249 | def default_abspath_handler(self, bin_name: BinName, **context) -> HostBinPath | None:
250 |
251 | # try searching for the bin_name in BinProvider.PATH first (fastest)
252 | try:
253 | abspath = super().default_abspath_handler(bin_name, **context)
254 | if abspath:
255 | return TypeAdapter(HostBinPath).validate_python(abspath)
256 | except ValueError:
257 | pass
258 |
259 | # fallback to using pip show to get the site-packages bin path
260 | output_lines = self._pip_show(bin_name)
261 | # For more information, please refer to
262 | # Location: /Volumes/NVME/Users/squash/Library/Python/3.11/lib/python/site-packages
263 | # Requires: brotli, certifi, mutagen, pycryptodomex, requests, urllib3, websockets
264 | # Required-by:
265 | try:
266 | location = [line for line in output_lines if line.startswith('Location: ')][0].split('Location: ', 1)[-1]
267 | except IndexError:
268 | return None
269 | PATH = str(Path(location).parent.parent.parent / 'bin')
270 | abspath = bin_abspath(str(bin_name), PATH=PATH)
271 | if abspath:
272 | return TypeAdapter(HostBinPath).validate_python(abspath)
273 | else:
274 | return None
275 |
276 | def default_version_handler(self, bin_name: BinName, abspath: Optional[HostBinPath]=None, **context) -> SemVer | None:
277 | # print(f'[*] {self.__class__.__name__}: Getting version for {bin_name}...')
278 |
279 | # try running --version first (fastest)
280 | try:
281 | version = super().default_version_handler(bin_name, abspath, **context)
282 | if version:
283 | return SemVer.parse(version)
284 | except ValueError:
285 | pass
286 |
287 | # fallback to using pip show to get the version (slower)
288 | output_lines = self._pip_show(bin_name)
289 | # Name: yt-dlp
290 | # Version: 1.3.0
291 | # Location: /Volumes/NVME/Users/squash/Library/Python/3.11/lib/python/site-packages
292 | try:
293 | version_str = [line for line in output_lines if line.startswith('Version: ')][0].split('Version: ', 1)[-1]
294 | return SemVer.parse(version_str)
295 | except Exception:
296 | return None
297 |
298 |
299 | if __name__ == "__main__":
300 | # Usage:
301 | # ./binprovider_pip.py load yt-dlp
302 | # ./binprovider_pip.py install pip
303 | # ./binprovider_pip.py get_version pip
304 | # ./binprovider_pip.py get_abspath pip
305 | result = pip = PipProvider()
306 |
307 | if len(sys.argv) > 1:
308 | result = func = getattr(pip, sys.argv[1]) # e.g. install
309 |
310 | if len(sys.argv) > 2:
311 | result = func(sys.argv[2]) # e.g. install ffmpeg
312 |
313 | print(result)
314 |
--------------------------------------------------------------------------------
/abx_pkg/binprovider_pyinfra.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | __package__ = 'abx_pkg'
3 |
4 | import os
5 | import sys
6 | import shutil
7 | from pathlib import Path
8 |
9 | from typing import Optional, Dict, Any
10 |
11 | from .base_types import BinProviderName, PATHStr, BinName, InstallArgs
12 | from .binprovider import BinProvider, OPERATING_SYSTEM, DEFAULT_PATH
13 |
14 | PYINFRA_INSTALLED = False
15 | PYINFRA_IMPORT_ERROR = None
16 | try:
17 | # from pyinfra import host
18 | from pyinfra import operations # noqa: F401
19 | from pyinfra.api import Config, Inventory, State
20 | from pyinfra.api.connect import connect_all
21 | from pyinfra.api.operation import add_op
22 | from pyinfra.api.operations import run_ops
23 | from pyinfra.api.exceptions import PyinfraError
24 |
25 | PYINFRA_INSTALLED = True
26 | except ImportError as err:
27 | PYINFRA_IMPORT_ERROR = err
28 | pass
29 |
30 |
31 |
32 |
33 | def pyinfra_package_install(pkg_names: InstallArgs, installer_module: str = "auto", installer_extra_kwargs: Optional[Dict[str, Any]] = None) -> str:
34 | if not PYINFRA_INSTALLED:
35 | raise RuntimeError("Pyinfra is not installed! To fix:\n pip install pyinfra") from PYINFRA_IMPORT_ERROR
36 |
37 | config = Config()
38 | inventory = Inventory((["@local"], {}))
39 | state = State(inventory=inventory, config=config)
40 |
41 | if isinstance(pkg_names, str):
42 | pkg_names = pkg_names.split(' ') # type: ignore
43 |
44 | connect_all(state)
45 |
46 | _sudo_user = None
47 | if installer_module == 'auto':
48 | is_macos = OPERATING_SYSTEM == "darwin"
49 | if is_macos:
50 | installer_module = 'operations.brew.packages'
51 | try:
52 | _sudo_user = Path(shutil.which('brew')).stat().st_uid
53 | except Exception:
54 | pass
55 | else:
56 | installer_module = 'operations.server.packages'
57 | else:
58 | # TODO: non-stock pyinfra modules from other libraries?
59 | assert installer_module.startswith('operations.')
60 |
61 | try:
62 | installer_module_op = eval(installer_module)
63 | except Exception as err:
64 | raise RuntimeError(f'Failed to import pyinfra installer_module {installer_module}: {err.__class__.__name__}') from err
65 |
66 | result = add_op(
67 | state,
68 | installer_module_op,
69 | name=f"Install system packages: {pkg_names}",
70 | packages=pkg_names,
71 | _sudo_user=_sudo_user,
72 | **(installer_extra_kwargs or {}),
73 | )
74 |
75 | succeeded = False
76 | try:
77 | run_ops(state)
78 | succeeded = True
79 | except PyinfraError:
80 | succeeded = False
81 |
82 | result = result[state.inventory.hosts["@local"]]
83 | result_text = f'Installing {pkg_names} on {OPERATING_SYSTEM} using Pyinfra {installer_module} {["failed", "succeeded"][succeeded]}\n{result.stdout}\n{result.stderr}'.strip()
84 |
85 | if succeeded:
86 | return result_text
87 |
88 | if "Permission denied" in result_text:
89 | raise PermissionError(
90 | f"Installing {pkg_names} failed! Need to be root to use package manager (retry with sudo, or install manually)"
91 | )
92 | raise Exception(f"Installing {pkg_names} failed! (retry with sudo, or install manually)\n{result_text}")
93 |
94 |
95 |
96 | class PyinfraProvider(BinProvider):
97 | name: BinProviderName = "pyinfra"
98 | INSTALLER_BIN: BinName = "pyinfra"
99 | PATH: PATHStr = os.environ.get("PATH", DEFAULT_PATH)
100 |
101 | pyinfra_installer_module: str = 'auto' # e.g. operations.apt.packages, operations.server.packages, etc.
102 | pyinfra_installer_kwargs: Dict[str, Any] = {}
103 |
104 |
105 | def default_install_handler(self, bin_name: str, packages: Optional[InstallArgs] = None, **context) -> str:
106 | packages = packages or self.get_packages(bin_name)
107 |
108 | return pyinfra_package_install(
109 | pkg_names=packages,
110 | installer_module=self.pyinfra_installer_module,
111 | installer_extra_kwargs=self.pyinfra_installer_kwargs,
112 | )
113 |
114 |
115 | if __name__ == "__main__":
116 | result = pyinfra = PyinfraProvider()
117 |
118 | if len(sys.argv) > 1:
119 | result = func = getattr(pyinfra, sys.argv[1]) # e.g. install
120 |
121 | if len(sys.argv) > 2:
122 | result = func(sys.argv[2]) # e.g. install ffmpeg
123 |
124 | print(result)
125 |
--------------------------------------------------------------------------------
/abx_pkg/models.py:
--------------------------------------------------------------------------------
1 | # pip install django-pydantic-field
2 |
3 | ### EXAMPLE USAGE
4 | #
5 | # from django.db import models
6 | # from django_pydantic_field import SchemaField
7 | #
8 | # from abx_pkg import BinProvider, EnvProvider, Binary
9 | #
10 | # DEFAULT_PROVIDER = EnvProvider()
11 | #
12 | # class MyModel(models.Model):
13 | # ...
14 | #
15 | # # SchemaField supports storing a single BinProvider/Binary in a field...
16 | # favorite_binprovider: BinProvider = SchemaField(default=DEFAULT_PROVIDER)
17 | #
18 | # # ... or inside a collection type like list[...] dict[...]
19 | # optional_binaries: list[Binary] = SchemaField(default=[])
20 | #
21 | # curl = Binary(name='curl', providers=[DEFAULT_PROVIDER]).load()
22 | #
23 | # obj = MyModel(optional_binaries=[curl])
24 | # obj.save()
25 | #
26 | # assert obj.favorite_binprovider == DEFAULT_PROVIDER
27 | # assert obj.optional_binaries[0].provider == DEFAULT_PROVIDER
28 |
--------------------------------------------------------------------------------
/abx_pkg/semver.py:
--------------------------------------------------------------------------------
1 | __package__ = 'abx_pkg'
2 |
3 | import re
4 | import subprocess
5 | from collections import namedtuple
6 |
7 | from typing import Any, Optional, TYPE_CHECKING
8 |
9 | from pydantic_core import ValidationError
10 | from pydantic import validate_call
11 |
12 | from .base_types import HostBinPath
13 |
14 |
15 | def is_semver_str(semver: Any) -> bool:
16 | if isinstance(semver, str):
17 | return (semver.count('.') == 2 and semver.replace('.', '').isdigit())
18 | return False
19 |
20 | def semver_to_str(semver: tuple[int, int, int] | str) -> str:
21 | if isinstance(semver, (list, tuple)):
22 | return '.'.join(str(chunk) for chunk in semver)
23 | if is_semver_str(semver):
24 | return semver
25 | raise ValidationError('Tried to convert invalid SemVer: {}'.format(semver))
26 |
27 |
28 | SemVerTuple = namedtuple('SemVerTuple', ('major', 'minor', 'patch'), defaults=(0, 0, 0))
29 | SemVerParsableTypes = str | tuple[str | int, ...] | list[str | int]
30 |
31 | class SemVer(SemVerTuple):
32 | major: int
33 | minor: int = 0
34 | patch: int = 0
35 |
36 | if TYPE_CHECKING:
37 | full_text: str | None = ''
38 |
39 | def __new__(cls, *args, full_text=None, **kwargs):
40 | # '1.1.1'
41 | if len(args) == 1 and is_semver_str(args[0]):
42 | result = SemVer.parse(args[0])
43 |
44 | # ('1', '2', '3')
45 | elif len(args) == 1 and isinstance(args[0], (tuple, list)):
46 | result = SemVer.parse(args[0])
47 |
48 | # (1, '2', None)
49 | elif not all(isinstance(arg, (int, type(None))) for arg in args):
50 | result = SemVer.parse(args)
51 |
52 | # (None)
53 | elif all(chunk in ('', 0, None) for chunk in (*args, *kwargs.values())):
54 | result = None
55 |
56 | # 1, 2, 3
57 | else:
58 | result = SemVerTuple.__new__(cls, *args, **kwargs)
59 |
60 | if result is not None:
61 | # add first line as extra hidden metadata so it can be logged without having to re-run version cmd
62 | result.full_text = full_text or str(result)
63 | return result
64 |
65 | @classmethod
66 | def parse(cls, version_stdout: SemVerParsableTypes) -> Optional['SemVer']:
67 | """
68 | parses a version tag string formatted like into (major, minor, patch) ints
69 | 'Google Chrome 124.0.6367.208' -> (124, 0, 6367)
70 | 'GNU Wget 1.24.5 built on darwin23.2.0.' -> (1, 24, 5)
71 | 'curl 8.4.0 (x86_64-apple-darwin23.0) ...' -> (8, 4, 0)
72 | '2024.04.09' -> (2024, 4, 9)
73 |
74 | """
75 | # print('INITIAL_VALUE', type(version_stdout).__name__, version_stdout)
76 |
77 | if isinstance(version_stdout, (tuple, list)):
78 | version_stdout = '.'.join(str(chunk) for chunk in version_stdout)
79 | elif isinstance(version_stdout, bytes):
80 | version_stdout = version_stdout.decode()
81 | elif not isinstance(version_stdout, str):
82 | version_stdout = str(version_stdout)
83 |
84 | # no text to work with, return None immediately
85 | if not version_stdout.strip():
86 | # raise Exception('Tried to parse semver from empty version output (is binary installed and available?)')
87 | return None
88 |
89 | just_numbers = lambda col: '.'.join([chunk for chunk in re.split(r'[\D]', col.lower().strip('v'), maxsplit=10) if chunk.isdigit()][:3]) # split on any non-num character e.g. 5.2.26(1)-release -> ['5', '2', '26', '1', '', '', ...]
90 | contains_semver = lambda col: (
91 | col.count('.') in (1, 2, 3)
92 | and all(chunk.isdigit() for chunk in col.split('.')[:3]) # first 3 chunks can only be nums
93 | )
94 |
95 | full_text = version_stdout.split('\n')[0].strip()
96 | first_line_columns = full_text.split()[:5]
97 | version_columns = list(filter(contains_semver, map(just_numbers, first_line_columns)))
98 |
99 | # could not find any column of first line that looks like a version number, despite there being some text
100 | if not version_columns:
101 | # raise Exception('Failed to parse semver from version command output: {}'.format(' '.join(first_line_columns)))
102 | return None
103 |
104 | # take first col containing a semver, and truncate it to 3 chunks (e.g. 2024.04.09.91) -> (2024, 04, 09)
105 | first_version_tuple = version_columns[0].split('.', 3)[:3]
106 |
107 | # print('FINAL_VALUE', first_version_tuple)
108 |
109 | return cls(*(int(chunk) for chunk in first_version_tuple), full_text=full_text)
110 |
111 | def __str__(self):
112 | return '.'.join(str(chunk) for chunk in self)
113 |
114 |
115 | # Not needed as long as we dont stray any further from a basic NamedTuple
116 | # if we start overloading more methods or it becomes a fully custom type, then we probably need this:
117 | # @classmethod
118 | # def __get_pydantic_core_schema__(cls, source: Type[Any], handler: GetCoreSchemaHandler) -> core_schema.CoreSchema:
119 | # default_schema = handler(source)
120 | # return core_schema.no_info_after_validator_function(
121 | # cls.parse,
122 | # default_schema,
123 | # serialization=core_schema.plain_serializer_function_ser_schema(
124 | # lambda semver: str(semver),
125 | # info_arg=False,
126 | # return_schema=core_schema.str_schema(),
127 | # ),
128 | # )
129 |
130 |
131 | # @validate_call
132 | def bin_version(bin_path: HostBinPath, args=("--version",)) -> SemVer | None:
133 | return SemVer(subprocess.run([str(bin_path), *args], stdout=subprocess.PIPE, text=True).stdout.strip())
134 |
--------------------------------------------------------------------------------
/abx_pkg/settings.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from django.conf import settings
4 | from django.utils.module_loading import import_string
5 |
6 |
7 | ABX_PKG_GET_ALL_BINARIES = getattr(settings, 'ABX_PKG_GET_ALL_BINARIES', 'abx_pkg.views.get_all_binaries')
8 | ABX_PKG_GET_BINARY = getattr(settings, 'ABX_PKG_GET_BINARY', 'abx_pkg.views.get_binary')
9 |
10 |
11 | if isinstance(ABX_PKG_GET_ALL_BINARIES, str):
12 | get_all_abx_pkg_binaries = import_string(ABX_PKG_GET_ALL_BINARIES)
13 | elif isinstance(ABX_PKG_GET_ALL_BINARIES, Callable):
14 | get_all_abx_pkg_binaries = ABX_PKG_GET_ALL_BINARIES
15 | else:
16 | raise ValueError('ABX_PKG_GET_ALL_BINARIES must be a function or dotted import path to a function')
17 |
18 | if isinstance(ABX_PKG_GET_BINARY, str):
19 | get_abx_pkg_binary = import_string(ABX_PKG_GET_BINARY)
20 | elif isinstance(ABX_PKG_GET_BINARY, Callable):
21 | get_abx_pkg_binary = ABX_PKG_GET_BINARY
22 | else:
23 | raise ValueError('ABX_PKG_GET_BINARY must be a function or dotted import path to a function')
24 |
25 |
26 |
--------------------------------------------------------------------------------
/abx_pkg/shallowbinary.py:
--------------------------------------------------------------------------------
1 | __package__ = 'abx_pkg'
2 |
3 |
4 | # Unfortunately it must be kept in the same file as BinProvider because of the circular type reference between them
5 | from .binprovider import ShallowBinary # noqa: F401
6 |
--------------------------------------------------------------------------------
/abx_pkg/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
--------------------------------------------------------------------------------
/abx_pkg/views.py:
--------------------------------------------------------------------------------
1 | # pip install django-admin-data-views
2 |
3 | from django.http import HttpRequest
4 | from django.utils.html import mark_safe
5 |
6 | from admin_data_views.typing import TableContext, ItemContext
7 | from admin_data_views.utils import render_with_table_view, render_with_item_view, ItemLink
8 |
9 | from .binary import Binary
10 |
11 |
12 | def get_all_binaries() -> list[Binary]:
13 | """Override this function implement getting the list of binaries to render"""
14 | return []
15 |
16 | def get_binary(name: str) -> Binary:
17 | """Override this function implement getting the list of binaries to render"""
18 |
19 | from . import settings
20 |
21 | for binary in settings.ABX_PKG_GET_ALL_BINARIES():
22 | if binary.name == key:
23 | return binary
24 | return None
25 |
26 |
27 |
28 | @render_with_table_view
29 | def binaries_list_view(request: HttpRequest, **kwargs) -> TableContext:
30 |
31 | assert request.user.is_superuser, 'Must be a superuser to view configuration settings.'
32 |
33 | from . import settings
34 |
35 | rows = {
36 | "Binary": [],
37 | "Found Version": [],
38 | "Provided By": [],
39 | "Found Abspath": [],
40 | "Overrides": [],
41 | "Description": [],
42 | }
43 |
44 | for binary in settings.get_all_abx_pkg_binaries():
45 | binary = binary.load_or_install()
46 |
47 | rows['Binary'].append(ItemLink(binary.name, key=binary.name))
48 | rows['Found Version'].append(binary.loaded_version)
49 | rows['Provided By'].append(binary.loaded_binprovider)
50 | rows['Found Abspath'].append(binary.loaded_abspath)
51 | rows['Overrides'].append(str(binary.overrides))
52 | rows['Description'].append(binary.description)
53 |
54 | return TableContext(
55 | title="Binaries",
56 | table=rows,
57 | )
58 |
59 | @render_with_item_view
60 | def binary_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext:
61 |
62 | assert request.user.is_superuser, 'Must be a superuser to view configuration settings.'
63 |
64 | from . import settings
65 |
66 | binary = settings.get_abx_pkg_binary(key)
67 |
68 | assert binary, f'Could not find a binary matching the specified name: {key}'
69 |
70 | binary = binary.load_or_install()
71 |
72 | return ItemContext(
73 | slug=key,
74 | title=key,
75 | data=[
76 | {
77 | "name": binary.name,
78 | "description": binary.description,
79 | "fields": {
80 | 'binprovider': binary.loaded_provider,
81 | 'abspath': binary.loaded_abspath,
82 | 'version': binary.loaded_version,
83 | 'is_script': binary.is_script,
84 | 'is_executable': binary.is_executable,
85 | 'is_valid': binary.is_valid,
86 | 'overrides': str(binary.overrides),
87 | 'providers': str(binary.binproviders_supported),
88 | },
89 | "help_texts": {
90 | # TODO
91 | },
92 | },
93 | ],
94 | )
95 |
--------------------------------------------------------------------------------
/bin/publish.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | if [[ "$1" == "" ]]; then
4 | echo "Usage: $0 "
5 | exit 1
6 | fi
7 |
8 | echo "Bumping to version $1"
9 |
10 | rm -Rf dist 2>/dev/null
11 | nano pyproject.toml
12 | git add -p .
13 | git commit -m "bump version to $1"
14 | git tag -f -a "v$1" -m "v$1"
15 | git push origin
16 | git push origin --tags -f
17 | uv lock
18 | uv sync
19 | uv build
20 | uv publish
21 |
--------------------------------------------------------------------------------
/django_example_project/README.md:
--------------------------------------------------------------------------------
1 | # Example Django Project
2 |
3 |
4 | ```bash
5 | git clone 'https://github.com/ArchiveBox/abx-pkg'
6 | cd abx-pkg
7 |
8 | pip install -e . # install abx_pkg from source
9 |
10 | cd django_example_project/ # then go into the demo project dir
11 |
12 | ./manage.py makemigrations # create any migrations if needed
13 | ./manage.py migrate # then run them to create the demo sqlite db
14 | ./manage.py createsuperuser # create an admin user to test out the UI
15 |
16 | ./manage.py runserver # then open http://127.0.0.1:8000/admin/
17 | ```
18 |
--------------------------------------------------------------------------------
/django_example_project/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == '__main__':
22 | main()
23 |
--------------------------------------------------------------------------------
/django_example_project/project/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ArchiveBox/abx-pkg/cf4a4f23ee57720186e8f907e32c75bed729035a/django_example_project/project/__init__.py
--------------------------------------------------------------------------------
/django_example_project/project/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from django_jsonform.widgets import JSONFormWidget
4 | from django_pydantic_field.v2.fields import PydanticSchemaField
5 |
6 | from project.models import Dependency
7 |
8 |
9 | def patch_schema_for_jsonform(schema):
10 | """recursively patch a schema dictionary in-place to fix any missing properties/keys on objects"""
11 |
12 | # base case: schema is type: "object" with no properties/keys
13 | if schema.get('type') == 'object' and not ('properties' in schema or 'keys' in schema):
14 | if 'default' in schema and isinstance(schema['default'], dict):
15 | schema['properties'] = {
16 | key: {"type": "string", "default": value}
17 | for key, value in schema['default'].items()
18 | }
19 | else:
20 | schema['properties'] = {}
21 | elif schema.get('type') == 'array' and not ('items' in schema):
22 | if 'default' in schema and isinstance(schema['default'], (tuple, list)):
23 | schema['items'] = {'type': 'string', 'default': schema['default']}
24 | else:
25 | schema['items'] = {'type': 'string', 'default': []}
26 |
27 | # recursive case: iterate through all values and process any sub-objects
28 | for key, value in schema.items():
29 | if isinstance(value, dict):
30 | patch_schema_for_jsonform(value)
31 |
32 |
33 |
34 | class PatchedJSONFormWidget(JSONFormWidget):
35 | def get_schema(self):
36 | self.schema = super().get_schema()
37 | patch_schema_for_jsonform(self.schema)
38 | return self.schema
39 |
40 |
41 |
42 | class DependencyAdmin(admin.ModelAdmin):
43 | formfield_overrides = {PydanticSchemaField: {"widget": PatchedJSONFormWidget}}
44 |
45 | admin.site.register(Dependency, DependencyAdmin)
46 |
--------------------------------------------------------------------------------
/django_example_project/project/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for project project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/django_example_project/project/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.0.6 on 2024-05-21 00:31
2 |
3 | import django.core.serializers.json
4 | import django_pydantic_field.compat.django
5 | import django_pydantic_field.fields
6 | import pydantic_pkgr.binary
7 | import pydantic_pkgr.binprovider
8 | from django.db import migrations, models
9 |
10 |
11 | class Migration(migrations.Migration):
12 |
13 | initial = True
14 |
15 | dependencies = [
16 | ]
17 |
18 | operations = [
19 | migrations.CreateModel(
20 | name='Dependency',
21 | fields=[
22 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23 | ('label', models.CharField(max_length=63)),
24 | ('default_binprovider', django_pydantic_field.fields.PydanticSchemaField(config=None, default={'name': 'env'}, encoder=django.core.serializers.json.DjangoJSONEncoder, schema=pydantic_pkgr.binprovider.BinProvider)),
25 | ('binaries', django_pydantic_field.fields.PydanticSchemaField(config=None, default=[], encoder=django.core.serializers.json.DjangoJSONEncoder, schema=django_pydantic_field.compat.django.GenericContainer(list, (pydantic_pkgr.binary.Binary,)))),
26 | ],
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/django_example_project/project/migrations/0002_alter_dependency_options_dependency_min_version.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.0.6 on 2024-05-21 02:39
2 |
3 | import django.core.serializers.json
4 | import django_pydantic_field.fields
5 | import pydantic_pkgr.semver
6 | from django.db import migrations
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | dependencies = [
12 | ('project', '0001_initial'),
13 | ]
14 |
15 | operations = [
16 | migrations.AlterModelOptions(
17 | name='dependency',
18 | options={'verbose_name_plural': 'Dependencies'},
19 | ),
20 | migrations.AddField(
21 | model_name='dependency',
22 | name='min_version',
23 | field=django_pydantic_field.fields.PydanticSchemaField(config=None, default=[0, 0, 1], encoder=django.core.serializers.json.DjangoJSONEncoder, schema=pydantic_pkgr.semver.SemVer),
24 | ),
25 | ]
26 |
--------------------------------------------------------------------------------
/django_example_project/project/migrations/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ArchiveBox/abx-pkg/cf4a4f23ee57720186e8f907e32c75bed729035a/django_example_project/project/migrations/__init__.py
--------------------------------------------------------------------------------
/django_example_project/project/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django_pydantic_field import SchemaField
3 |
4 | from abx_pkg import BinProvider, EnvProvider, Binary, SemVer
5 |
6 |
7 | DEFAULT_PROVIDER = EnvProvider()
8 |
9 |
10 | class Dependency(models.Model):
11 | """Example model implementing fields that contain BinProvider and Binary data"""
12 |
13 | label = models.CharField(max_length=63)
14 |
15 | default_binprovider: BinProvider = SchemaField(default=DEFAULT_PROVIDER)
16 |
17 | binaries: list[Binary] = SchemaField(default=[])
18 |
19 | min_version: SemVer = SchemaField(default=(0,0,1))
20 |
21 | class Meta:
22 | verbose_name_plural = 'Dependencies'
23 |
--------------------------------------------------------------------------------
/django_example_project/project/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for project project.
3 |
4 | Generated by 'django-admin startproject' using Django 5.0.6.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.0/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/5.0/ref/settings/
11 | """
12 |
13 | from pathlib import Path
14 |
15 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
16 | BASE_DIR = Path(__file__).resolve().parent.parent
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = 'django-insecure-uppz@=qfk)lw+neoz3x)xq&vr715i@9c9-@yi=)!trr12rnb0+'
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | 'django.contrib.admin',
35 | 'django.contrib.auth',
36 | 'django.contrib.contenttypes',
37 | 'django.contrib.sessions',
38 | 'django.contrib.messages',
39 | 'django.contrib.staticfiles',
40 |
41 | 'admin_data_views',
42 | 'django_jsonform',
43 |
44 | 'abx_pkg',
45 |
46 | 'project',
47 | ]
48 |
49 | MIDDLEWARE = [
50 | 'django.middleware.security.SecurityMiddleware',
51 | 'django.contrib.sessions.middleware.SessionMiddleware',
52 | 'django.middleware.common.CommonMiddleware',
53 | 'django.middleware.csrf.CsrfViewMiddleware',
54 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
55 | 'django.contrib.messages.middleware.MessageMiddleware',
56 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
57 | ]
58 |
59 | ROOT_URLCONF = 'project.urls'
60 |
61 | TEMPLATES = [
62 | {
63 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
64 | 'DIRS': [],
65 | 'APP_DIRS': True,
66 | 'OPTIONS': {
67 | 'context_processors': [
68 | 'django.template.context_processors.debug',
69 | 'django.template.context_processors.request',
70 | 'django.contrib.auth.context_processors.auth',
71 | 'django.contrib.messages.context_processors.messages',
72 | ],
73 | },
74 | },
75 | ]
76 |
77 | WSGI_APPLICATION = 'project.wsgi.application'
78 |
79 |
80 | # Database
81 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases
82 |
83 | DATABASES = {
84 | 'default': {
85 | 'ENGINE': 'django.db.backends.sqlite3',
86 | 'NAME': BASE_DIR / 'db.sqlite3',
87 | }
88 | }
89 |
90 |
91 | # Password validation
92 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
93 |
94 | AUTH_PASSWORD_VALIDATORS = [
95 | {
96 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
97 | },
98 | {
99 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
100 | },
101 | {
102 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
103 | },
104 | {
105 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
106 | },
107 | ]
108 |
109 |
110 | # Internationalization
111 | # https://docs.djangoproject.com/en/5.0/topics/i18n/
112 |
113 | LANGUAGE_CODE = 'en-us'
114 |
115 | TIME_ZONE = 'UTC'
116 |
117 | USE_I18N = True
118 |
119 | USE_TZ = True
120 |
121 |
122 | # Static files (CSS, JavaScript, Images)
123 | # https://docs.djangoproject.com/en/5.0/howto/static-files/
124 |
125 | STATIC_URL = 'static/'
126 |
127 | # Default primary key field type
128 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
129 |
130 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
131 |
132 |
133 | ABX_PKG_GET_ALL_BINARIES = 'project.views.get_all_binaries'
134 | ABX_PKG_GET_BINARY = 'project.views.get_binary'
135 |
136 |
137 | ADMIN_DATA_VIEWS = {
138 | "NAME": "Environment",
139 | "URLS": [
140 | {
141 | "route": "binaries/",
142 | "view": "abx_pkg.views.binaries_list_view",
143 | "name": "binaries",
144 | "items": {
145 | "route": "/",
146 | "view": "abx_pkg.views.binary_detail_view",
147 | "name": "binary",
148 | },
149 | },
150 | # Coming soon: binprovider_list_view + binprovider_detail_view ...
151 | ],
152 | }
153 |
--------------------------------------------------------------------------------
/django_example_project/project/urls.py:
--------------------------------------------------------------------------------
1 | """
2 | URL configuration for project project.
3 |
4 | The `urlpatterns` list routes URLs to views. For more information please see:
5 | https://docs.djangoproject.com/en/5.0/topics/http/urls/
6 | Examples:
7 | Function views
8 | 1. Add an import: from my_app import views
9 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
10 | Class-based views
11 | 1. Add an import: from other_app.views import Home
12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
13 | Including another URLconf
14 | 1. Import the include() function: from django.urls import include, path
15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
16 | """
17 | from django.contrib import admin
18 | from django.urls import path
19 |
20 | urlpatterns = [
21 | path('admin/', admin.site.urls),
22 | ]
23 |
--------------------------------------------------------------------------------
/django_example_project/project/views.py:
--------------------------------------------------------------------------------
1 | from abx_pkg.binary import Binary
2 |
3 |
4 | def get_all_binaries() -> list[Binary]:
5 | """Override this function implement getting the list of binaries to render"""
6 | return [
7 | Binary(name='bash'),
8 | Binary(name='python'),
9 | Binary(name='brew'),
10 | Binary(name='git'),
11 | ]
12 |
13 | def get_binary(name: str) -> Binary:
14 | """Override this function implement getting the list of binaries to render"""
15 |
16 | from abx_pkg import settings
17 |
18 | for binary in settings.get_all_abx_pkg_binaries():
19 | if binary.name == name:
20 | return binary
21 | return None
22 |
--------------------------------------------------------------------------------
/django_example_project/project/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for project project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [project]
2 | name = "abx-pkg"
3 | version = "0.6.0"
4 | description = "System package manager interfaces with Python type hints"
5 | authors = [
6 | {name = "Nick Sweeting", email = "abx-pkg-pyproject-toml@sweeting.me"},
7 | ]
8 | requires-python = ">=3.10"
9 | license = {text = "MIT"}
10 | readme = "README.md"
11 | keywords = [
12 | "pydantic",
13 | "packagemanager",
14 | "apt",
15 | "brew",
16 | "pip",
17 | "system",
18 | "dependencies",
19 | ]
20 | classifiers = [
21 | "Programming Language :: Python",
22 | "Programming Language :: Python :: 3",
23 | "Programming Language :: Python :: 3 :: Only",
24 | "Programming Language :: Python :: 3.10",
25 | "Programming Language :: Python :: 3.11",
26 | "Programming Language :: Python :: 3.12",
27 | "Framework :: Django",
28 | "Framework :: Django :: 4.0",
29 | "Framework :: Django :: 4.1",
30 | "Framework :: Django :: 4.2",
31 | "Framework :: Django :: 5.0",
32 | "Framework :: Django :: 5.1",
33 | "Environment :: Web Environment",
34 | "Intended Audience :: Developers",
35 | "Natural Language :: English",
36 | ]
37 |
38 | dependencies = [
39 | "pip>=24.3.1",
40 | "typing-extensions>=4.11.0",
41 | "platformdirs>=4.3.6",
42 | "pydantic>=2.7.1",
43 | "pydantic-core>=2.18.2",
44 | ]
45 |
46 | [project.optional-dependencies]
47 | pyinfra = [
48 | 'pyinfra>=2.6.1',
49 | ]
50 | ansible = [
51 | 'ansible>=10.5.0',
52 | 'ansible-core>=2.17.5',
53 | 'ansible-runner>=2.4.0',
54 | ]
55 | all = [
56 | "abx-pkg[pyinfra,ansible]",
57 | ]
58 |
59 | [build-system]
60 | requires = ["hatchling"]
61 | build-backend = "hatchling.build"
62 |
63 | [tool.uv]
64 | dev-dependencies = [
65 | "mypy>=1.10.0",
66 | "pyright",
67 | "django>=4.0",
68 | "django-stubs>=5.0.0",
69 | "django-admin-data-views>=0.3.1",
70 | "django-pydantic-field>=0.3.9",
71 | "django-jsonform>=2.22.0",
72 | ]
73 |
74 | [tool.mypy]
75 | mypy_path = "abx_pkg"
76 | python_version = "3.10"
77 | warn_return_any = "True"
78 | warn_unused_configs = "True"
79 | plugins = [
80 | "mypy_django_plugin.main",
81 | ]
82 |
83 | [tool.pyright]
84 | include = ["abx_pkg"]
85 | exclude = [
86 | "**/node_modules",
87 | "**/__pycache__",
88 | "**/migrations",
89 | ]
90 | reportMissingImports = true
91 | reportMissingTypeStubs = false
92 | pythonVersion = "3.10"
93 | pythonPlatform = "Linux"
94 |
95 |
96 | [project.urls]
97 | Homepage = "https://github.com/ArchiveBox/abx-pkg"
98 | Source = "https://github.com/ArchiveBox/abx-pkg"
99 | Documentation = "https://github.com/ArchiveBox/abx-pkg"
100 | "Bug Tracker" = "https://github.com/ArchiveBox/abx-pkg/issues"
101 | Changelog = "https://github.com/ArchiveBox/abx-pkg/releases"
102 | Donate = "https://github.com/ArchiveBox/ArchiveBox/wiki/Donations"
103 |
--------------------------------------------------------------------------------
/tests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 |
3 | import os
4 | import sys
5 | import shutil
6 | import unittest
7 | import subprocess
8 | from io import StringIO
9 | from unittest import mock
10 | from pathlib import Path
11 |
12 | # from rich import print
13 |
14 | from abx_pkg import (
15 | BinProvider, EnvProvider, Binary, SemVer, BinProviderOverrides,
16 | PipProvider, NpmProvider, AptProvider, BrewProvider,
17 | )
18 |
19 |
20 | class TestSemVer(unittest.TestCase):
21 |
22 | def test_parsing(self):
23 | self.assertEqual(SemVer(None), None)
24 | self.assertEqual(SemVer(''), None)
25 | self.assertEqual(SemVer.parse(''), None)
26 | self.assertEqual(SemVer(1), (1, 0, 0))
27 | self.assertEqual(SemVer(1, 2), (1, 2, 0))
28 | self.assertEqual(SemVer('1.2+234234'), (1, 2, 234234))
29 | self.assertEqual(SemVer('1.2+beta'), (1, 2, 0))
30 | self.assertEqual(SemVer('1.2.4(1)+beta'), (1, 2, 4))
31 | self.assertEqual(SemVer('1.2+beta(3)'), (1, 2, 3))
32 | self.assertEqual(SemVer('1.2+6-be1ta(4)'), (1, 2, 6))
33 | self.assertEqual(SemVer('1.2 curl(8)beta-4'), (1, 2, 0))
34 | self.assertEqual(SemVer('1.2+curl(8)beta-4'), (1, 2, 8))
35 | self.assertEqual(SemVer((1, 2, 3)), (1, 2, 3))
36 | self.assertEqual(getattr(SemVer((1, 2, 3)), 'full_text'), '1.2.3')
37 | self.assertEqual(SemVer(('1', '2', '3')), (1, 2, 3))
38 | self.assertEqual(SemVer.parse('5.6.7'), (5, 6, 7))
39 | self.assertEqual(SemVer.parse('124.0.6367.208'), (124, 0, 6367))
40 | self.assertEqual(SemVer.parse('Google Chrome 124.1+234.234'), (124, 1, 234))
41 | self.assertEqual(SemVer.parse('Google Ch1rome 124.0.6367.208'), (124, 0, 6367))
42 | self.assertEqual(SemVer.parse('Google Chrome 124.0.6367.208+beta_234. 234.234.123\n123.456.324'), (124, 0, 6367))
43 | self.assertEqual(getattr(SemVer.parse('Google Chrome 124.0.6367.208+beta_234. 234.234.123\n123.456.324'), 'full_text'), 'Google Chrome 124.0.6367.208+beta_234. 234.234.123')
44 | self.assertEqual(SemVer.parse('Google Chrome'), None)
45 |
46 |
47 | class TestBinProvider(unittest.TestCase):
48 |
49 | def test_python_env(self):
50 | provider = EnvProvider()
51 |
52 | python_bin = provider.load('python')
53 | self.assertEqual(python_bin, provider.load_or_install('python'))
54 |
55 | self.assertEqual(python_bin.loaded_version, SemVer('{}.{}.{}'.format(*sys.version_info[:3])))
56 | self.assertEqual(python_bin.loaded_abspath, Path(sys.executable).absolute())
57 | self.assertEqual(python_bin.loaded_respath, Path(sys.executable).resolve())
58 | self.assertTrue(python_bin.is_valid)
59 | self.assertTrue(python_bin.is_executable)
60 | self.assertFalse(python_bin.is_script)
61 | self.assertTrue(bool(str(python_bin))) # easy way to make sure serializing doesnt throw an error
62 | self.assertEqual(str(python_bin.loaded_binprovider.INSTALLER_BINARY.abspath), str(shutil.which('which')))
63 |
64 |
65 | def test_bash_env(self):
66 | envprovider = EnvProvider()
67 |
68 | SYS_BASH_VERSION = subprocess.check_output('bash --version', shell=True, text=True).split('\n')[0]
69 |
70 | bash_bin = envprovider.load_or_install('bash')
71 | self.assertEqual(bash_bin.loaded_version, SemVer(SYS_BASH_VERSION))
72 | self.assertGreater(bash_bin.loaded_version, SemVer('3.0.0'))
73 | self.assertEqual(bash_bin.loaded_abspath, Path(shutil.which('bash')))
74 | self.assertTrue(bash_bin.is_valid)
75 | self.assertTrue(bash_bin.is_executable)
76 | self.assertFalse(bash_bin.is_script)
77 | self.assertTrue(bool(str(bash_bin))) # easy way to make sure serializing doesnt throw an error
78 |
79 | def test_overrides(self):
80 |
81 | class TestRecord:
82 | called_default_abspath_getter = False
83 | called_default_version_getter = False
84 | called_default_packages_getter = False
85 | called_custom_install_handler = False
86 |
87 | def custom_version_getter():
88 | return '1.2.3'
89 |
90 | def custom_abspath_getter(self, bin_name, **context):
91 | assert self.__class__.__name__ == 'CustomProvider'
92 | return '/usr/bin/true'
93 |
94 | class CustomProvider(BinProvider):
95 | name: str = 'custom'
96 |
97 | overrides: BinProviderOverrides = {
98 | '*': {
99 | 'abspath': 'self.default_abspath_getter', # test staticmethod referenced via dotted notation on self.
100 | 'packages': 'self.default_packages_getter', # test classmethod referenced via dotted notation on self.
101 | 'version': 'self.default_version_getter', # test normal method referenced via dotted notation on self.
102 | 'install': None, # test intentionally nulled handler
103 | },
104 | 'somebin': {
105 | 'abspath': custom_abspath_getter, # test external func that takes self, bin_name, and **context
106 | 'version': custom_version_getter, # test external func that takes no args
107 | 'packages': ['literal', 'return', 'value'], # test literal return value
108 | },
109 | 'abc': {
110 | 'packages': 'self.alternate_packages_getter', # test classmethod that overrules default handler
111 | },
112 | }
113 |
114 | @staticmethod
115 | def default_abspath_getter():
116 | TestRecord.called_default_abspath_getter = True
117 | return '/bin/bash'
118 |
119 | @classmethod
120 | def default_packages_getter(cls, bin_name: str, **context):
121 | TestRecord.called_default_packages_getter = True
122 | return None
123 |
124 | def default_version_getter(self, bin_name: str, **context):
125 | TestRecord.called_default_version_getter = True
126 | return '999.999.999'
127 |
128 |
129 | @classmethod
130 | def alternate_packages_getter(cls, bin_name: str, **context):
131 | TestRecord.called_default_packages_getter = True
132 | return ['abc', 'def']
133 |
134 | def on_install(self, bin_name: str, **context):
135 | raise NotImplementedError('whattt')
136 |
137 | provider = CustomProvider()
138 | provider._dry_run = True
139 |
140 | self.assertFalse(TestRecord.called_default_abspath_getter)
141 | self.assertFalse(TestRecord.called_default_version_getter)
142 | self.assertFalse(TestRecord.called_default_packages_getter)
143 | self.assertFalse(TestRecord.called_custom_install_handler)
144 |
145 | # test default abspath getter
146 | self.assertEqual(provider.get_abspath('doesnotexist'), Path('/bin/bash'))
147 | self.assertTrue(TestRecord.called_default_abspath_getter)
148 |
149 | # test custom abspath getter
150 | self.assertEqual(provider.get_abspath('somebin'), Path('/usr/bin/true')) # test that Callable getter that takes self, bin_name, **context works + result is auto-cast to Path
151 |
152 | # test default version getter
153 | self.assertEqual(provider.get_version('doesnotexist'), SemVer('999.999.999')) # test that normal 'self.some_method' dot referenced getter works and result is auto-cast to SemVer
154 | self.assertTrue(TestRecord.called_default_version_getter)
155 |
156 | # test custom version getter
157 | self.assertEqual(provider.get_version('somebin'), SemVer('1.2.3')) # test that remote Callable func getter that takes no args works and str result is auto-cast to SemVer
158 |
159 | # test default packages getter
160 | self.assertEqual(provider.get_packages('doesnotexist'), ('doesnotexist',)) # test that it fallsback to [bin_name] by default if getter returns None
161 | self.assertTrue(TestRecord.called_default_packages_getter)
162 | self.assertEqual(provider.get_packages('abc'), ('abc', 'def')) # test that classmethod getter funcs work
163 |
164 | # test custom packages getter
165 | self.assertEqual(provider.get_packages('somebin'), ('literal', 'return', 'value')) # test that literal return values in overrides work
166 |
167 | # test install handler
168 | exc = None
169 | try:
170 | provider.install('doesnotexist')
171 | except Exception as err:
172 | exc = err
173 | self.assertIsInstance(exc, AssertionError)
174 | self.assertTrue('BinProvider(name=custom) has no install handler implemented for Binary(name=doesnotexist)' in str(exc))
175 |
176 |
177 | class TestBinary(unittest.TestCase):
178 |
179 | def test_python_bin(self):
180 | envprovider = EnvProvider()
181 |
182 | python_bin = Binary(name='python', binproviders=[envprovider])
183 |
184 | self.assertIsNone(python_bin.loaded_binprovider)
185 | self.assertIsNone(python_bin.loaded_abspath)
186 | self.assertIsNone(python_bin.loaded_version)
187 |
188 | python_bin = python_bin.load()
189 |
190 | shallow_bin = envprovider.load_or_install('python')
191 | assert shallow_bin and python_bin.loaded_binprovider
192 | self.assertEqual(python_bin.loaded_binprovider, shallow_bin.loaded_binprovider)
193 | self.assertEqual(python_bin.loaded_abspath, shallow_bin.loaded_abspath)
194 | self.assertEqual(python_bin.loaded_version, shallow_bin.loaded_version)
195 | self.assertEqual(python_bin.loaded_sha256, shallow_bin.loaded_sha256)
196 |
197 | self.assertEqual(python_bin.loaded_version, SemVer('{}.{}.{}'.format(*sys.version_info[:3])))
198 | self.assertEqual(python_bin.loaded_abspath, Path(sys.executable).absolute())
199 | self.assertEqual(python_bin.loaded_respath, Path(sys.executable).resolve())
200 | self.assertTrue(python_bin.is_valid)
201 | self.assertTrue(python_bin.is_executable)
202 | self.assertFalse(python_bin.is_script)
203 | self.assertTrue(bool(str(python_bin))) # easy way to make sure serializing doesnt throw an error
204 |
205 |
206 | def flatten(xss):
207 | return [x for xs in xss for x in xs]
208 |
209 | class InstallTest(unittest.TestCase):
210 |
211 | def install_with_binprovider(self, provider, binary):
212 |
213 | binary_bin = binary.load_or_install()
214 | provider_bin = provider.load_or_install(bin_name=binary.name)
215 | # print(binary_bin, binary_bin.bin_dir, binary_bin.loaded_abspath)
216 | # print('\n'.join(f'{provider}={path}' for provider, path in binary.loaded_abspaths.items()), '\n')
217 | # print()
218 | try:
219 | self.assertEqual(binary_bin.loaded_binprovider, provider_bin.loaded_binprovider)
220 | except AssertionError:
221 | print('binary_bin', dict(binary_bin.loaded_binprovider))
222 | print('provider_bin', dict(provider_bin.loaded_binprovider))
223 | raise
224 | self.assertEqual(binary_bin.loaded_abspath, provider_bin.loaded_abspath)
225 | self.assertEqual(binary_bin.loaded_version, provider_bin.loaded_version)
226 | self.assertEqual(binary_bin.loaded_sha256, provider_bin.loaded_sha256)
227 |
228 | self.assertIn(binary_bin.loaded_abspath, flatten(binary_bin.loaded_abspaths.values()))
229 | self.assertIn(str(binary_bin.bin_dir), flatten(PATH.split(':') for PATH in binary_bin.loaded_bin_dirs.values()))
230 |
231 | PATH = provider.PATH
232 | bin_abspath = shutil.which(binary.name, path=PATH)
233 | assert bin_abspath, f'Could not find {binary.name} in PATH={PATH}'
234 | VERSION = SemVer.parse(subprocess.check_output(f'{bin_abspath} --version', shell=True, text=True))
235 | ABSPATH = Path(bin_abspath).absolute().resolve()
236 |
237 | self.assertEqual(binary_bin.loaded_version, VERSION)
238 | self.assertIn(binary_bin.loaded_abspath, provider.get_abspaths(binary_bin.name))
239 | self.assertEqual(binary_bin.loaded_respath, ABSPATH)
240 | self.assertTrue(binary_bin.is_valid)
241 | self.assertTrue(binary_bin.is_executable)
242 | self.assertFalse(binary_bin.is_script)
243 | self.assertTrue(bool(str(binary_bin))) # easy way to make sure serializing doesnt throw an error
244 | # print(provider.PATH)
245 | # print()
246 | # print()
247 | # print(binary.bin_filename, binary.bin_dir, binary.loaded_abspaths)
248 | # print()
249 | # print()
250 | # print(provider.name, 'PATH=', provider.PATH, 'ABSPATHS=', provider.get_abspaths(bin_name=binary_bin.name))
251 | return provider_bin
252 |
253 | def test_env_provider(self):
254 | provider = EnvProvider()
255 | binary = Binary(name='wget', binproviders=[provider]).load()
256 | self.install_with_binprovider(provider, binary)
257 |
258 | def test_pip_provider(self):
259 | # pipprovider = PipProvider()
260 | pipprovider = PipProvider(pip_venv=os.environ.get('VIRTUAL_ENV', None))
261 | # print('PIP BINPROVIDER', pipprovider.INSTALLER_BIN_ABSPATH, 'PATH=', pipprovider.PATH)
262 | binary = Binary(name='yt-dlp', binproviders=[pipprovider])
263 | self.install_with_binprovider(pipprovider, binary)
264 |
265 | def test_npm_provider(self):
266 | npmprovider = NpmProvider()
267 | # print(provider.PATH)
268 | binary = Binary(name='tsx', binproviders=[npmprovider])
269 | self.install_with_binprovider(npmprovider, binary)
270 |
271 | @mock.patch("sys.stderr")
272 | @mock.patch("subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0, stdout='', stderr=''))
273 | def test_dry_run_doesnt_exec(self, mock_run, _mock_stderr):
274 | pipprovider = PipProvider().get_provider_with_overrides(dry_run=True)
275 | pipprovider.install(bin_name='doesnotexist')
276 | mock_run.assert_not_called()
277 |
278 | @mock.patch("sys.stderr", new_callable=StringIO)
279 | def test_dry_run_prints_stderr(self, mock_stderr):
280 | pipprovider = PipProvider()
281 | binary = Binary(name='doesnotexist', binproviders=[pipprovider])
282 | binary.install(dry_run=True)
283 |
284 | self.assertIn('DRY RUN', mock_stderr.getvalue())
285 |
286 | def test_brew_provider(self):
287 | # print(provider.PATH)
288 | os.environ['HOMEBREW_NO_AUTO_UPDATE'] = 'True'
289 | os.environ['HOMEBREW_NO_INSTALL_CLEANUP'] = 'True'
290 | os.environ['HOMEBREW_NO_ENV_HINTS'] = 'True'
291 |
292 | is_on_windows = sys.platform.lower().startswith('win') or os.name == 'nt'
293 | is_on_macos = 'darwin' in sys.platform.lower()
294 | is_on_linux = 'linux' in sys.platform.lower()
295 | has_brew = shutil.which('brew')
296 | # has_apt = shutil.which('dpkg') is not None
297 |
298 | provider = BrewProvider()
299 | if has_brew:
300 | self.assertTrue(provider.PATH)
301 | self.assertTrue(provider.is_valid)
302 | else:
303 | # print('SHOULD NOT HAVE BREW, but got', provider.INSTALLER_BIN_ABSPATH, 'PATH=', provider.PATH)
304 | self.assertFalse(provider.is_valid)
305 |
306 | exception = None
307 | result = None
308 | try:
309 | binary = Binary(name='wget', binproviders=[provider])
310 | result = self.install_with_binprovider(provider, binary)
311 | except Exception as err:
312 | exception = err
313 |
314 |
315 | if is_on_macos or (is_on_linux and has_brew):
316 | self.assertTrue(has_brew)
317 | if exception:
318 | raise exception
319 | self.assertIsNone(exception)
320 | self.assertTrue(result)
321 | elif is_on_windows or (is_on_linux and not has_brew):
322 | self.assertFalse(has_brew)
323 | self.assertIsInstance(exception, Exception)
324 | self.assertFalse(result)
325 | else:
326 | raise exception
327 |
328 |
329 | def test_apt_provider(self):
330 | is_on_windows = sys.platform.startswith('win') or os.name == 'nt'
331 | is_on_macos = 'darwin' in sys.platform
332 | is_on_linux = 'linux' in sys.platform
333 | # has_brew = shutil.which('brew') is not None
334 | has_apt = shutil.which('apt-get') is not None
335 |
336 |
337 | exception = None
338 | result = None
339 | provider = AptProvider()
340 | if has_apt:
341 | self.assertTrue(provider.PATH)
342 | else:
343 | self.assertFalse(provider.PATH)
344 | try:
345 | # print(provider.PATH)
346 | binary = Binary(name='wget', binproviders=[provider])
347 | result = self.install_with_binprovider(provider, binary)
348 | except Exception as err:
349 | exception = err
350 |
351 |
352 | if is_on_linux:
353 | self.assertTrue(has_apt)
354 | if exception:
355 | raise exception
356 | self.assertIsNone(exception)
357 | self.assertTrue(result)
358 | elif is_on_windows or is_on_macos:
359 | self.assertFalse(has_apt)
360 | self.assertIsInstance(exception, Exception)
361 | self.assertFalse(result)
362 | else:
363 | raise exception
364 |
365 |
366 | if __name__ == '__main__':
367 | unittest.main()
368 |
--------------------------------------------------------------------------------