akebi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
akebi/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from akebi.bun import Bun
akebi/bun.py ADDED
@@ -0,0 +1,217 @@
1
+ import contextlib
2
+ import hashlib
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import stat
7
+ import subprocess
8
+ import typing as t
9
+ from pathlib import Path
10
+ from tempfile import TemporaryDirectory
11
+ from zipfile import ZipFile
12
+
13
+ from pydantic import (
14
+ BaseModel,
15
+ ConfigDict,
16
+ Field,
17
+ TypeAdapter,
18
+ ValidationInfo,
19
+ field_validator,
20
+ validate_call,
21
+ )
22
+ from pydantic_extra_types.semantic_version import SemanticVersion
23
+ from rich.status import Status
24
+
25
+ from akebi import utils
26
+
27
+
28
+ class Bun(BaseModel):
29
+ model_config = ConfigDict(frozen=True)
30
+
31
+ app_name: str = None
32
+ """
33
+ An identifier under which Bun installations created by this object are to be isolated.
34
+ """
35
+
36
+ # In practice, this will always be a `SemanticVersion` object.
37
+ version: SemanticVersion | t.Literal["latest"] = Field(None, validate_default=True)
38
+ """
39
+ The version of Bun to use. Leave empty to use the latest version locally available. Pass "latest" to use the
40
+ latest version, period.
41
+ """
42
+
43
+ @field_validator("version", mode="before")
44
+ @classmethod
45
+ def validate_version(cls, v, info: ValidationInfo) -> SemanticVersion | str:
46
+ if v is None:
47
+ installed_versions = []
48
+
49
+ for directory in utils.get_bun_dir(info.data.get("app_name")).glob("*/"):
50
+ try:
51
+ installed_versions.append(
52
+ TypeAdapter(SemanticVersion).validate_python(directory.name)
53
+ )
54
+ except ValueError:
55
+ pass
56
+
57
+ if installed_versions:
58
+ return max(installed_versions)
59
+ else:
60
+ v = "latest"
61
+
62
+ if isinstance(v, str) and v == "latest":
63
+ with utils.github_client(api=True) as gh:
64
+ releases = (
65
+ gh.get("/repos/oven-sh/bun/releases").raise_for_status().json()
66
+ )
67
+
68
+ return releases[0]["tag_name"].split("-v")[-1]
69
+
70
+ return v
71
+
72
+ @property
73
+ def bin_path(self) -> Path:
74
+ """
75
+ The absolute path to the Bun executable.
76
+
77
+ This only returns where the executable *should* be; it does not guarantee that it exists.
78
+ """
79
+ return utils.get_bun_dir(self.app_name) / str(self.version) / "bin" / "bun"
80
+
81
+ @property
82
+ def checksum(self) -> str | None:
83
+ """
84
+ The checksum of the file at the intended location of the Bun executable.
85
+ """
86
+ if self.bin_path.is_file():
87
+ return hashlib.sha256(self.bin_path.read_bytes()).hexdigest()
88
+
89
+ @property
90
+ def stored_checksum(self) -> str | None:
91
+ """
92
+ The checksum of the Bun executable at the time it was downloaded.
93
+ """
94
+ with utils.state(table="bun") as bun_db:
95
+ return bun_db.get(str(self.bin_path))
96
+
97
+ @property
98
+ def is_installed(self) -> bool:
99
+ """
100
+ Whether this Bun is installed.
101
+ """
102
+ return self.bin_path.is_file() and self.checksum == self.stored_checksum
103
+
104
+ def setup(self, *, force: bool = False) -> t.Self:
105
+ """
106
+ Install this Bun if it isn't installed already.
107
+
108
+ Parameters
109
+ ----------
110
+ force: bool, default=False
111
+ Install this Bun even if it's already installed.
112
+
113
+ Returns
114
+ -------
115
+ Bun
116
+ This object.
117
+ """
118
+ if self.is_installed and not force:
119
+ return self
120
+
121
+ with Status(f"Installing Bun v{self.version}..."):
122
+ target = {"host": platform.system().casefold()}
123
+
124
+ architecture = platform.machine().casefold()
125
+
126
+ match architecture:
127
+ case "x86_64" | "amd64":
128
+ target["architecture"] = "x64"
129
+ case "arm64":
130
+ target["architecture"] = "aarch64"
131
+ case _:
132
+ target["architecture"] = architecture
133
+
134
+ if target["host"] == "linux":
135
+ libc = utils.guess_libc()
136
+
137
+ if libc == "musl":
138
+ target["libc"] = libc
139
+
140
+ if utils.needs_baseline_build():
141
+ target["build"] = "baseline"
142
+
143
+ asset = f"bun-{'-'.join(target.values())}.zip"
144
+
145
+ with utils.github_client() as gh:
146
+ asset_resp = gh.get(
147
+ f"/oven-sh/bun/releases/download/bun-v{self.version}/{asset}",
148
+ follow_redirects=True,
149
+ )
150
+
151
+ if asset_resp.status_code == 404:
152
+ # noinspection PyArgumentList
153
+ raise Exception(
154
+ f"No Bun {self.version} release with name {asset} could be found"
155
+ )
156
+
157
+ asset_resp.raise_for_status()
158
+
159
+ with TemporaryDirectory() as tmpdir:
160
+ with contextlib.chdir(tmpdir):
161
+ bun_zip = Path("bun.zip")
162
+ bun_zip.write_bytes(asset_resp.content)
163
+
164
+ ZipFile(bun_zip).extractall()
165
+
166
+ bun_bin = next(Path.cwd().glob("**/bun"))
167
+ bun_bin.chmod(bun_bin.stat().st_mode | stat.S_IEXEC)
168
+
169
+ self.bin_path.parent.mkdir(parents=True, exist_ok=True)
170
+
171
+ shutil.move(bun_bin, self.bin_path)
172
+
173
+ with utils.state(table="bun") as bun_db:
174
+ bun_db[str(self.bin_path)] = self.checksum
175
+
176
+ return self
177
+
178
+ @validate_call()
179
+ def __call__(
180
+ self,
181
+ args: t.Annotated[list | str, Field(default_factory=list)],
182
+ /,
183
+ **kwargs,
184
+ ) -> subprocess.CompletedProcess:
185
+ """
186
+ Invoke this Bun.
187
+
188
+ Parameters
189
+ ----------
190
+ args: list or str, optional, default=[]
191
+ Command-line arguments to pass to the invocation.
192
+
193
+ kwargs: dict, default={}
194
+ Arbitrary keyword arguments to pass to ``subprocess.run``.
195
+
196
+ Returns
197
+ -------
198
+ subprocess.CompletedProcess
199
+ The associated ``subprocess.CompletedProcess`` object.
200
+ """
201
+ self.setup()
202
+
203
+ if isinstance(args, list):
204
+ args = [self.bin_path] + args
205
+ elif isinstance(args, str):
206
+ args = f"{self.bin_path} {args}"
207
+
208
+ env = kwargs.get("env", os.environ)
209
+
210
+ # If the Bun binary isn't on PATH then things like create-next-app will be displeased.
211
+ if isinstance(env, t.MutableMapping):
212
+ kwargs["env"] = {
213
+ **env,
214
+ "PATH": str(self.bin_path.parent) + os.pathsep + env.get("PATH", ""),
215
+ }
216
+
217
+ return subprocess.run(args, **kwargs)
akebi/utils.py ADDED
@@ -0,0 +1,182 @@
1
+ import ctypes
2
+ import platform
3
+ import subprocess
4
+ import typing as t
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+
8
+ import httpx2 as httpx
9
+ import kokomikke
10
+ import platformdirs
11
+ from sqlitedict import SqliteDict
12
+
13
+
14
+ def github_client(*, api: bool = False, **kwargs) -> httpx.Client:
15
+ """
16
+ Return an ``httpx.Client`` for GitHub.
17
+
18
+ Parameters
19
+ ----------
20
+ api: bool, default=False
21
+ If True, return an `httpx.Client` for the GitHub API.
22
+
23
+ kwargs: dict, default={}
24
+ Arbitrary keyword arguments to pass to ``httpx.Client``.
25
+ """
26
+ return httpx.Client(
27
+ base_url="https://api.github.com" if api else "https://github.com",
28
+ event_hooks={
29
+ "request": [_github_client_request_token_hook],
30
+ "response": [_github_client_rate_limit_hook],
31
+ },
32
+ **kwargs,
33
+ )
34
+
35
+
36
+ def get_cache_dir() -> Path:
37
+ """
38
+ Return Akebi's cache directory.
39
+ """
40
+ path = platformdirs.user_cache_path("akebi", appauthor=False)
41
+ path.mkdir(exist_ok=True, parents=True)
42
+
43
+ return path
44
+
45
+
46
+ def get_data_dir() -> Path:
47
+ """
48
+ Return Akebi's data directory.
49
+ """
50
+ path = platformdirs.user_data_path("akebi", appauthor=False)
51
+ path.mkdir(parents=True, exist_ok=True)
52
+
53
+ return path
54
+
55
+
56
+ def get_bun_dir(app_name: str = None) -> Path:
57
+ """
58
+ Return the directory where Akebi-managed Bun installs are located.
59
+ """
60
+ if app_name:
61
+ path = get_cache_dir() / "isolated" / app_name / "bun"
62
+ else:
63
+ path = get_cache_dir() / "shared" / "bun"
64
+
65
+ path.mkdir(parents=True, exist_ok=True)
66
+
67
+ return path
68
+
69
+
70
+ def guess_libc() -> t.Literal["gnu", "musl"] | None:
71
+ """
72
+ Make a best-effort attempt at determining the C standard library implementation on a Linux system.
73
+ """
74
+ if platform.system().lower() != "linux":
75
+ return None
76
+
77
+ # Check os-release for known musl distributions
78
+ try:
79
+ os_release = platform.freedesktop_os_release()
80
+ except OSError:
81
+ pass
82
+ else:
83
+ identities = {os_release.get("ID")}.union(os_release.get("ID_LIKE", "").split())
84
+
85
+ if identities.intersection({"alpine", "postmarketos", "chimera"}):
86
+ return "musl"
87
+
88
+ # Read the output of `ldd --version`
89
+ try:
90
+ ldd_command = subprocess.run(
91
+ ["ldd", "--version"], capture_output=True, text=True
92
+ )
93
+ except FileNotFoundError:
94
+ pass
95
+ else:
96
+ # `ldd --version` on musl systems exits nonzero and writes to stderr. why? you tell me
97
+ ldd_info = ldd_command.stdout + ldd_command.stderr
98
+
99
+ if "gnu" in ldd_info.casefold():
100
+ return "gnu"
101
+ elif "musl" in ldd_info.casefold():
102
+ return "musl"
103
+
104
+ # Check which libc the Python interpreter is linked against
105
+ match platform.libc_ver()[0]:
106
+ case "glibc":
107
+ return "gnu"
108
+ case "musl":
109
+ return "musl"
110
+
111
+ # Check /lib and /lib64 for files beginning with ld-linux- or ld-musl-
112
+ for directory in ["/lib", "/lib64"]:
113
+ for prefix, libc in zip(["ld-linux-", "ld-musl-"], ["gnu", "musl"]):
114
+ if next(Path(directory).glob(f"{prefix}*"), None):
115
+ return libc
116
+
117
+ # If all else fails, assume glibc and hope for the best
118
+ return "gnu"
119
+
120
+
121
+ def needs_baseline_build() -> bool:
122
+ """
123
+ Determine whether the host machine requires a baseline build of Bun.
124
+ """
125
+
126
+ if platform.machine() in ["arm64", "aarch64"]:
127
+ return False
128
+
129
+ match platform.system().lower():
130
+ case "darwin":
131
+ try:
132
+ sysctl = subprocess.check_output(["sysctl", "-a"], text=True)
133
+ except FileNotFoundError, subprocess.CalledProcessError:
134
+ return True
135
+ else:
136
+ return "avx2" not in sysctl.casefold()
137
+
138
+ case "linux":
139
+ cpu_info = Path("/proc/cpuinfo")
140
+ return not (
141
+ cpu_info.is_file() and "avx2" in cpu_info.read_text().casefold()
142
+ )
143
+
144
+ case "windows":
145
+ return not bool(ctypes.windll.kernel32.IsProcessorFeaturePresent(40))
146
+
147
+ return True
148
+
149
+
150
+ @contextmanager
151
+ def state(table: str = "unnamed") -> SqliteDict:
152
+ """
153
+ Context manager for accessing Akebi's key-value store.
154
+
155
+ Parameters
156
+ ----------
157
+ table: str, default="unnamed"
158
+ The table to access.
159
+ """
160
+ with SqliteDict(get_data_dir() / "akebi.db", tablename=table) as db:
161
+ yield db
162
+ db.commit()
163
+
164
+
165
+ def _github_client_request_token_hook(request: httpx.Request) -> None:
166
+ """
167
+ HTTPX event hook to attach a GitHub access token, if one can be found, to requests to GitHub.
168
+ """
169
+ if token := kokomikke.search():
170
+ request.headers["Authorization"] = f"Bearer {token}"
171
+
172
+
173
+ def _github_client_rate_limit_hook(response: httpx.Response) -> None:
174
+ """
175
+ HTTPX event hook to handle HTTP 429 errors in responses from GitHub.
176
+ """
177
+ if response.status_code == 429:
178
+ raise Exception(
179
+ "GitHub is rate limiting you. You can probably fix this by setting the "
180
+ "GITHUB_TOKEN environment variable or logging into the GitHub CLI (https://cli.github.com); your "
181
+ "credentials will automatically be attached to future GitHub requests."
182
+ )
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: akebi
3
+ Version: 0.1.0
4
+ Summary: Manage Bun installations from Python
5
+ Author-email: celsius narhwal <hello@celsiusnarhwal.dev>
6
+ License-File: LICENSE.md
7
+ Requires-Python: >=3.14
8
+ Requires-Dist: httpx2>=2.4.0
9
+ Requires-Dist: kokomikke>=0.1.4
10
+ Requires-Dist: loguru>=0.7.3
11
+ Requires-Dist: platformdirs>=4.10.0
12
+ Requires-Dist: pydantic-extra-types>=2.11.1
13
+ Requires-Dist: pydantic-settings>=2.14.1
14
+ Requires-Dist: pydantic>=2.13.4
15
+ Requires-Dist: rich>=15.0.0
16
+ Requires-Dist: semver>=3.0.4
17
+ Requires-Dist: sqlitedict>=2.1.0
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Akebi
21
+
22
+ Akebi allows Python programs to manage [Bun](https://bun.com) installations. It is intended for programs that need
23
+ to install or run Node.js packages but do not wish to depend on the end user having an appropriate runtime or package
24
+ manager installed.
25
+
26
+ ## Installation
27
+
28
+ ```shell
29
+ pip install akebi
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ First, create an `akebi.Bun` object:
35
+
36
+ ```python
37
+ from akebi import Bun
38
+
39
+ bun = Bun()
40
+ bun = Bun(version="1.3.14") # use a specific version of Bun
41
+ bun = Bun(version="latest") # use the latest version of Bun
42
+ ```
43
+
44
+ You can then invoke Bun by calling the `Bun` object you just created. A `Bun` object takes a `subprocess.run`-esque list or string
45
+ containing command-line arguments.
46
+
47
+ ```python
48
+ from akebi import Bun
49
+
50
+ bun = Bun()
51
+
52
+ bun(["run", "./my-script.ts"])
53
+ bun(["add", "next"])
54
+ bun(["create", "docusaurus"])
55
+ ```
56
+
57
+ Akebi will automatically install Bun as necessary, but you can also install it manually with `Bun.setup`:
58
+
59
+ ```python
60
+ bun.setup()
61
+
62
+ # or, to forcibly overwrite existing installations of the same version:
63
+
64
+ bun.setup(force=True)
65
+ ```
66
+
67
+ Under the hood, Akebi uses `subprocess.run` to invoke Bun. Keyword arguments passed to `Bun` objects will be passed
68
+ through to `subprocess.run`, e.g.:
69
+
70
+ ```python
71
+ bun("update tailwindcss", shell=True, capture_output=True, text=True)
72
+ ```
73
+
74
+ ### Isolated installations
75
+
76
+ If you want to ensure the Bun installations used by your program aren't touched by anything else that uses
77
+ Akebi, you can set `Bun.app_name`:
78
+
79
+ ```python
80
+ bun = Bun(app_name="com.example.myapp")
81
+ ```
82
+
83
+ Bun installations created by your program will be located under an `app_name` subdirectory. You should choose
84
+ an app name that's consistent within your program and not likely be taken by other programs that use
85
+ Akebi.
@@ -0,0 +1,8 @@
1
+ akebi/__init__.py,sha256=FPE54glVDeEhp5nhkb4xhDdIx1vkWqXM-ZMtq6rdWnY,26
2
+ akebi/bun.py,sha256=1o1osEUof_s4Ha5dEfaUPnTmHuv0Yaj3mf7dNplAS0U,6538
3
+ akebi/utils.py,sha256=NgvTk3ThJzg2Zrf7KLtBdYosyCaA5Xw8YAlOXMrsllY,5235
4
+ akebi-0.1.0.dist-info/METADATA,sha256=_ryY2Ex7IsgHb-IG8OHFT40ysaQRQOK9jG369TBAzZQ,2319
5
+ akebi-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ akebi-0.1.0.dist-info/entry_points.txt,sha256=CYzeoyViLwn_s_HxQTTNzLyyxULPY1DMn-_99Lf-Tsc,37
7
+ akebi-0.1.0.dist-info/licenses/LICENSE.md,sha256=hA4i2yCQXys8petYmompsCotSkMDAtoJ5WDiDAsuoGA,1100
8
+ akebi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ akebi = akebi:main
@@ -0,0 +1,25 @@
1
+ # The MIT License (MIT)
2
+
3
+ Copyright © 2026-present celsius narhwal
4
+
5
+ Permission is hereby granted, free of charge, to any person
6
+ obtaining a copy of this software and associated documentation
7
+ files (the “Software”), to deal in the Software without
8
+ restriction, including without limitation the rights to use,
9
+ copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the
11
+ Software is furnished to do so, subject to the following
12
+ conditions:
13
+
14
+ The above copyright notice and this permission notice shall be
15
+ included in all copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
18
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
19
+ OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
20
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
21
+ HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
22
+ WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
23
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
24
+ OTHER DEALINGS IN THE SOFTWARE.
25
+