typvend 0.1.0__tar.gz
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.
- typvend-0.1.0/.github/workflows/lint.yml +15 -0
- typvend-0.1.0/.github/workflows/publish-pypi.yml +48 -0
- typvend-0.1.0/.github/workflows/test.yml +21 -0
- typvend-0.1.0/.gitignore +19 -0
- typvend-0.1.0/.python-version +1 -0
- typvend-0.1.0/LICENSE +21 -0
- typvend-0.1.0/PKG-INFO +79 -0
- typvend-0.1.0/README.md +48 -0
- typvend-0.1.0/justfile +20 -0
- typvend-0.1.0/prek.toml +42 -0
- typvend-0.1.0/pyproject.toml +57 -0
- typvend-0.1.0/src/typvend/__init__.py +7 -0
- typvend-0.1.0/src/typvend/__main__.py +6 -0
- typvend-0.1.0/src/typvend/cli.py +202 -0
- typvend-0.1.0/src/typvend/downloader.py +88 -0
- typvend-0.1.0/src/typvend/index.py +98 -0
- typvend-0.1.0/src/typvend/scanner.py +62 -0
- typvend-0.1.0/tests/__init__.py +1 -0
- typvend-0.1.0/tests/test_downloader.py +69 -0
- typvend-0.1.0/tests/test_index.py +32 -0
- typvend-0.1.0/tests/test_scanner.py +48 -0
- typvend-0.1.0/uv.lock +417 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: publish-pypi
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: publish-pypi-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: false
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
build:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v6
|
|
21
|
+
- uses: astral-sh/setup-uv@v8.1.0
|
|
22
|
+
with:
|
|
23
|
+
python-version: "3.14"
|
|
24
|
+
- name: Build distributions
|
|
25
|
+
run: uv build
|
|
26
|
+
- name: Upload distributions
|
|
27
|
+
uses: actions/upload-artifact@v4
|
|
28
|
+
with:
|
|
29
|
+
name: python-package-distributions
|
|
30
|
+
path: dist/
|
|
31
|
+
|
|
32
|
+
publish:
|
|
33
|
+
if: startsWith(github.ref, 'refs/tags/v')
|
|
34
|
+
needs: build
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment:
|
|
37
|
+
name: pypi
|
|
38
|
+
url: https://pypi.org/p/sylphy
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write
|
|
41
|
+
steps:
|
|
42
|
+
- name: Download distributions
|
|
43
|
+
uses: actions/download-artifact@v8
|
|
44
|
+
with:
|
|
45
|
+
name: python-package-distributions
|
|
46
|
+
path: dist/
|
|
47
|
+
- name: Publish package distributions to PyPI
|
|
48
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Test
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: astral-sh/setup-uv@v8.1.0
|
|
18
|
+
with:
|
|
19
|
+
python-version: ${{ matrix.python-version }}
|
|
20
|
+
- run: uv sync --group dev
|
|
21
|
+
- run: uv run pytest
|
typvend-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# Local test outputs
|
|
13
|
+
test_*/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
|
|
16
|
+
# Editor / IA agents
|
|
17
|
+
.idea/
|
|
18
|
+
.claude/
|
|
19
|
+
.antigravitycli/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
typvend-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Diego Alvarez S.
|
|
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.
|
typvend-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: typvend
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typst Package Vendoring CLI
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 Diego Alvarez S.
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Requires-Dist: niquests>=3.18.0
|
|
29
|
+
Requires-Dist: platformdirs>=4.9
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# typvend — Typst Package Vendoring CLI
|
|
33
|
+
|
|
34
|
+
`typvend` is a lightweight Python CLI utility designed to vendor official Typst packages locally for offline development, sandboxed builds, or containerized production CI/CD workflows.
|
|
35
|
+
|
|
36
|
+
## Why?
|
|
37
|
+
|
|
38
|
+
Typst downloads packages on the fly at compile time with no official way to pre-download them.
|
|
39
|
+
This can be problematic for offline compilation and read-only production environments (like containers).
|
|
40
|
+
The solution is to either run the compilation once to fetch the packages or download them manually.
|
|
41
|
+
|
|
42
|
+
`typvend` simplifies this, downloading packages to the default Typst cache path or any directory you choose (then point Typst to it via `--package-cache-path`), in two ways:
|
|
43
|
+
- **Explicit:** `add <pkg>[@<version>]` — download specific packages by name, with a version or `@latest`.
|
|
44
|
+
- **Scan:** recursively find all `@preview/<pkg>:<version>` imports in `.typ` files and vendor them in one go.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Install and run instantly using uvx / pipx
|
|
50
|
+
uvx typvend --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Global options:
|
|
54
|
+
- `-o`, `--output DIR` — Custom directory to extract packages (defaults to native OS Typst search path).
|
|
55
|
+
- `--namespace NS` — Custom namespace (defaults to `preview`).
|
|
56
|
+
- `-f`, `--force` — Re-download package even if it already exists.
|
|
57
|
+
- `-v`, `--verbose` — Enable verbose output logs.
|
|
58
|
+
|
|
59
|
+
### 1. Adding Packages Explicitly
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# Download latest version of fontawesome
|
|
63
|
+
uvx typvend add fontawesome
|
|
64
|
+
|
|
65
|
+
# Download specific versions
|
|
66
|
+
uvx typvend add fontawesome@0.5.0 cetz
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 2. Scanning Project Directories
|
|
70
|
+
|
|
71
|
+
Recursively searches a file or directory for package imports and vendors all discovered packages in one command:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# Scan a templates directory and output packages to typst cache folder
|
|
75
|
+
uvx typvend scan ./templates
|
|
76
|
+
|
|
77
|
+
# Scan and output to a custom directory (e.g. for Docker cache stages)
|
|
78
|
+
uvx typvend scan ./templates --output /typst-packages
|
|
79
|
+
```
|
typvend-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# typvend — Typst Package Vendoring CLI
|
|
2
|
+
|
|
3
|
+
`typvend` is a lightweight Python CLI utility designed to vendor official Typst packages locally for offline development, sandboxed builds, or containerized production CI/CD workflows.
|
|
4
|
+
|
|
5
|
+
## Why?
|
|
6
|
+
|
|
7
|
+
Typst downloads packages on the fly at compile time with no official way to pre-download them.
|
|
8
|
+
This can be problematic for offline compilation and read-only production environments (like containers).
|
|
9
|
+
The solution is to either run the compilation once to fetch the packages or download them manually.
|
|
10
|
+
|
|
11
|
+
`typvend` simplifies this, downloading packages to the default Typst cache path or any directory you choose (then point Typst to it via `--package-cache-path`), in two ways:
|
|
12
|
+
- **Explicit:** `add <pkg>[@<version>]` — download specific packages by name, with a version or `@latest`.
|
|
13
|
+
- **Scan:** recursively find all `@preview/<pkg>:<version>` imports in `.typ` files and vendor them in one go.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Install and run instantly using uvx / pipx
|
|
19
|
+
uvx typvend --help
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Global options:
|
|
23
|
+
- `-o`, `--output DIR` — Custom directory to extract packages (defaults to native OS Typst search path).
|
|
24
|
+
- `--namespace NS` — Custom namespace (defaults to `preview`).
|
|
25
|
+
- `-f`, `--force` — Re-download package even if it already exists.
|
|
26
|
+
- `-v`, `--verbose` — Enable verbose output logs.
|
|
27
|
+
|
|
28
|
+
### 1. Adding Packages Explicitly
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Download latest version of fontawesome
|
|
32
|
+
uvx typvend add fontawesome
|
|
33
|
+
|
|
34
|
+
# Download specific versions
|
|
35
|
+
uvx typvend add fontawesome@0.5.0 cetz
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 2. Scanning Project Directories
|
|
39
|
+
|
|
40
|
+
Recursively searches a file or directory for package imports and vendors all discovered packages in one command:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Scan a templates directory and output packages to typst cache folder
|
|
44
|
+
uvx typvend scan ./templates
|
|
45
|
+
|
|
46
|
+
# Scan and output to a custom directory (e.g. for Docker cache stages)
|
|
47
|
+
uvx typvend scan ./templates --output /typst-packages
|
|
48
|
+
```
|
typvend-0.1.0/justfile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Development commands for typvend
|
|
2
|
+
|
|
3
|
+
# Run all formatting and linting checks
|
|
4
|
+
check: format lint typecheck test
|
|
5
|
+
|
|
6
|
+
# Format code using ruff
|
|
7
|
+
format:
|
|
8
|
+
uv run ruff format .
|
|
9
|
+
|
|
10
|
+
# Lint code using ruff
|
|
11
|
+
lint:
|
|
12
|
+
uv run ruff check .
|
|
13
|
+
|
|
14
|
+
# Run type checking using pyrefly
|
|
15
|
+
typecheck:
|
|
16
|
+
uv run pyrefly check
|
|
17
|
+
|
|
18
|
+
# Run unit tests using pytest
|
|
19
|
+
test:
|
|
20
|
+
uv run pytest
|
typvend-0.1.0/prek.toml
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#:schema https://www.schemastore.org/prek.json
|
|
2
|
+
|
|
3
|
+
exclude = "^.idea/|^uv\\.lock$"
|
|
4
|
+
|
|
5
|
+
[[repos]]
|
|
6
|
+
repo = "https://github.com/pre-commit/pre-commit-hooks"
|
|
7
|
+
rev = "v6.0.0"
|
|
8
|
+
hooks = [
|
|
9
|
+
{ id = "check-yaml" },
|
|
10
|
+
{ id = "check-toml" },
|
|
11
|
+
{ id = "end-of-file-fixer" },
|
|
12
|
+
{ id = "trailing-whitespace" }
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[[repos]]
|
|
16
|
+
repo = "local"
|
|
17
|
+
hooks = [
|
|
18
|
+
{
|
|
19
|
+
id = "ruff-format",
|
|
20
|
+
name = "ruff-format",
|
|
21
|
+
entry = "uv run ruff format",
|
|
22
|
+
language = "system",
|
|
23
|
+
pass_filenames = false,
|
|
24
|
+
always_run = true
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id = "ruff-check",
|
|
28
|
+
name = "ruff-check",
|
|
29
|
+
entry = "uv run ruff check --fix",
|
|
30
|
+
language = "system",
|
|
31
|
+
pass_filenames = false,
|
|
32
|
+
always_run = true
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id = "pyrefly",
|
|
36
|
+
name = "pyrefly",
|
|
37
|
+
entry = "uv run pyrefly check",
|
|
38
|
+
language = "system",
|
|
39
|
+
pass_filenames = false,
|
|
40
|
+
always_run = true
|
|
41
|
+
},
|
|
42
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "typvend"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Typst Package Vendoring CLI"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { file = "LICENSE" }
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"platformdirs>=4.9",
|
|
10
|
+
"niquests>=3.18.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
typvend = "typvend.cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
"ruff>=0.4",
|
|
24
|
+
"pyrefly>=1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/typvend"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
target-version = "py311"
|
|
32
|
+
line-length = 100
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["ALL"]
|
|
36
|
+
ignore = [
|
|
37
|
+
"COM812", # Conflicts with formatter
|
|
38
|
+
"ISC001", # Conflicts with formatter
|
|
39
|
+
"S202", # tarfile.extractall is safe as we use filter/validation
|
|
40
|
+
"TRY301", # Abstract raise is too pedantic
|
|
41
|
+
"TRY400", # We explicitly log or swallow tracebacks based on verbose flag
|
|
42
|
+
"TRY401", # Redundant exception object is preferred for clarity
|
|
43
|
+
"BLE001", # Graceful top-level exception catching is standard in CLIs
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint.pydocstyle]
|
|
47
|
+
convention = "google"
|
|
48
|
+
|
|
49
|
+
[tool.ruff.lint.per-file-ignores]
|
|
50
|
+
"tests/**/*.py" = [
|
|
51
|
+
"S101", # asserts are standard in pytest
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[tool.pyrefly]
|
|
55
|
+
project-includes = [
|
|
56
|
+
"**/*.py*",
|
|
57
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Command-line interface for the typvend tool.
|
|
2
|
+
|
|
3
|
+
This module sets up the argument parser, handles the subcommands 'add' and 'scan',
|
|
4
|
+
and configures logging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import niquests
|
|
14
|
+
import platformdirs
|
|
15
|
+
|
|
16
|
+
from typvend.downloader import download_package
|
|
17
|
+
from typvend.index import resolve_latest_version
|
|
18
|
+
from typvend.scanner import scan_path
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("typvend")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_default_output() -> Path:
|
|
24
|
+
"""Returns the platform-specific default Typst package directory.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A Path object pointing to the system package directory.
|
|
28
|
+
"""
|
|
29
|
+
return platformdirs.user_data_path("typst") / "packages"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_package_arg(pkg: str) -> tuple[str, str]:
|
|
33
|
+
"""Parses a package argument in format name[@version].
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
pkg: A string of the form "name" or "name@version".
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A tuple (name, version) where version is "latest" if not specified.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If the package name is empty or contains invalid characters.
|
|
43
|
+
"""
|
|
44
|
+
if "@" in pkg:
|
|
45
|
+
parts = pkg.split("@", 1)
|
|
46
|
+
name = parts[0]
|
|
47
|
+
version = parts[1] or "latest"
|
|
48
|
+
else:
|
|
49
|
+
name = pkg
|
|
50
|
+
version = "latest"
|
|
51
|
+
|
|
52
|
+
if not name or not re.fullmatch(r"[a-zA-Z0-9_-]+", name):
|
|
53
|
+
msg = f"Invalid package name: '{name}'. Only alphanumeric, hyphens, underscores allowed."
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
return name, version
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def handle_add(args: argparse.Namespace) -> int:
|
|
60
|
+
"""Handles the 'add' subcommand.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
args: Parsed command-line arguments.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
0 if all packages were successfully vendored, 1 otherwise.
|
|
67
|
+
"""
|
|
68
|
+
namespace: str = args.namespace
|
|
69
|
+
output_dir = Path(args.output) if args.output else get_default_output()
|
|
70
|
+
force: bool = args.force
|
|
71
|
+
|
|
72
|
+
failed = False
|
|
73
|
+
|
|
74
|
+
# Type refinement
|
|
75
|
+
packages: list[str] = args.packages
|
|
76
|
+
|
|
77
|
+
for pkg_arg in packages:
|
|
78
|
+
name, version = parse_package_arg(pkg_arg)
|
|
79
|
+
try:
|
|
80
|
+
if version == "latest":
|
|
81
|
+
logger.info("Resolving latest version for %s...", name)
|
|
82
|
+
version = resolve_latest_version(name, namespace)
|
|
83
|
+
logger.info("Latest version resolved to %s", version)
|
|
84
|
+
|
|
85
|
+
download_package(
|
|
86
|
+
name=name,
|
|
87
|
+
version=version,
|
|
88
|
+
output_dir=output_dir,
|
|
89
|
+
namespace=namespace,
|
|
90
|
+
force=force,
|
|
91
|
+
)
|
|
92
|
+
except (ValueError, TypeError, niquests.RequestException, OSError):
|
|
93
|
+
failed = True
|
|
94
|
+
logger.error("Error vendoring package '%s'", pkg_arg, exc_info=args.verbose)
|
|
95
|
+
|
|
96
|
+
return 1 if failed else 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def handle_scan(args: argparse.Namespace) -> int:
|
|
100
|
+
"""Handles the 'scan' subcommand.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
args: Parsed command-line arguments.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
0 if all discovered packages were successfully vendored, 1 otherwise.
|
|
107
|
+
"""
|
|
108
|
+
namespace: str = args.namespace
|
|
109
|
+
output_dir = Path(args.output) if args.output else get_default_output()
|
|
110
|
+
force: bool = args.force
|
|
111
|
+
scan_target = Path(args.path)
|
|
112
|
+
|
|
113
|
+
if not scan_target.exists():
|
|
114
|
+
logger.error("Scan path does not exist: %s", scan_target)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
logger.info("Scanning %s for package imports...", scan_target)
|
|
118
|
+
packages = scan_path(scan_target, namespace)
|
|
119
|
+
logger.info("Discovered %d package(s): %s", len(packages), packages)
|
|
120
|
+
|
|
121
|
+
if not packages:
|
|
122
|
+
logger.info("No packages found to vendor.")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
failed = False
|
|
126
|
+
for name, version in sorted(packages):
|
|
127
|
+
try:
|
|
128
|
+
download_package(
|
|
129
|
+
name=name,
|
|
130
|
+
version=version,
|
|
131
|
+
output_dir=output_dir,
|
|
132
|
+
namespace=namespace,
|
|
133
|
+
force=force,
|
|
134
|
+
)
|
|
135
|
+
except (ValueError, TypeError, niquests.RequestException, OSError):
|
|
136
|
+
failed = True
|
|
137
|
+
logger.error("Error vendoring package '%s:%s'", name, version, exc_info=args.verbose)
|
|
138
|
+
|
|
139
|
+
return 1 if failed else 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
"""Main entry point for the CLI."""
|
|
144
|
+
parent_parser = argparse.ArgumentParser(add_help=False)
|
|
145
|
+
parent_parser.add_argument(
|
|
146
|
+
"-o", "--output", help="Custom output directory for vendored packages"
|
|
147
|
+
)
|
|
148
|
+
parent_parser.add_argument(
|
|
149
|
+
"--namespace",
|
|
150
|
+
default="preview",
|
|
151
|
+
help="Package namespace (default: preview)",
|
|
152
|
+
)
|
|
153
|
+
parent_parser.add_argument(
|
|
154
|
+
"-f",
|
|
155
|
+
"--force",
|
|
156
|
+
action="store_true",
|
|
157
|
+
help="Re-download package even if destination already exists",
|
|
158
|
+
)
|
|
159
|
+
parent_parser.add_argument(
|
|
160
|
+
"-v",
|
|
161
|
+
"--verbose",
|
|
162
|
+
action="store_true",
|
|
163
|
+
help="Enable verbose output logging",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
parser = argparse.ArgumentParser(description="typvend — Typst Package Vendoring CLI")
|
|
167
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
168
|
+
|
|
169
|
+
add_parser = subparsers.add_parser(
|
|
170
|
+
"add", parents=[parent_parser], help="Add explicit package(s) by name"
|
|
171
|
+
)
|
|
172
|
+
add_parser.add_argument(
|
|
173
|
+
"packages",
|
|
174
|
+
nargs="+",
|
|
175
|
+
help="Package name(s) optionally with version (e.g. fontawesome or fontawesome@0.6.0)",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
scan_parser = subparsers.add_parser(
|
|
179
|
+
"scan",
|
|
180
|
+
parents=[parent_parser],
|
|
181
|
+
help="Scan files/directories and vendor all discovered package imports",
|
|
182
|
+
)
|
|
183
|
+
scan_parser.add_argument("path", help="Path to file or directory to scan for imports")
|
|
184
|
+
|
|
185
|
+
args = parser.parse_args()
|
|
186
|
+
|
|
187
|
+
# Configure logging
|
|
188
|
+
log_level = logging.INFO if args.verbose else logging.WARNING
|
|
189
|
+
logging.basicConfig(
|
|
190
|
+
level=log_level,
|
|
191
|
+
format="%(levelname)s: %(message)s",
|
|
192
|
+
)
|
|
193
|
+
logger.setLevel(log_level)
|
|
194
|
+
|
|
195
|
+
if args.command == "add":
|
|
196
|
+
code = handle_add(args)
|
|
197
|
+
elif args.command == "scan":
|
|
198
|
+
code = handle_scan(args)
|
|
199
|
+
else:
|
|
200
|
+
code = 1
|
|
201
|
+
|
|
202
|
+
sys.exit(code)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Module for downloading and extracting Typst packages.
|
|
2
|
+
|
|
3
|
+
This module provides functions to fetch a package tarball from the Typst
|
|
4
|
+
repository, verify its paths to prevent directory traversal, and extract
|
|
5
|
+
its contents to the local vendor directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
import tarfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import niquests
|
|
15
|
+
|
|
16
|
+
from typvend import __version__
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def download_package(
|
|
22
|
+
name: str,
|
|
23
|
+
version: str,
|
|
24
|
+
output_dir: Path,
|
|
25
|
+
namespace: str = "preview",
|
|
26
|
+
*,
|
|
27
|
+
force: bool = False,
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""Downloads and extracts a Typst package to the local directory.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
name: The name of the package.
|
|
33
|
+
version: The package version (e.g. "0.6.0").
|
|
34
|
+
output_dir: The base vendor output directory.
|
|
35
|
+
namespace: The namespace of the package (e.g. "preview").
|
|
36
|
+
force: If True, overwrite the package even if it already exists.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True if the package was downloaded and extracted, False if it was
|
|
40
|
+
skipped because it already exists and force was False.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If a directory traversal is detected in the tarball.
|
|
44
|
+
niquests.RequestException: If the package download fails.
|
|
45
|
+
"""
|
|
46
|
+
dest_dir = output_dir / namespace / name / version
|
|
47
|
+
if dest_dir.exists() and not force:
|
|
48
|
+
logger.info(
|
|
49
|
+
"Skipping package %s:%s (already exists at %s)",
|
|
50
|
+
name,
|
|
51
|
+
version,
|
|
52
|
+
dest_dir,
|
|
53
|
+
)
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
url = f"https://packages.typst.org/{namespace}/{name}-{version}.tar.gz"
|
|
57
|
+
logger.info("Downloading %s...", url)
|
|
58
|
+
headers = {"User-Agent": f"typvend/{__version__}"}
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
response = niquests.get(url, headers=headers, timeout=30)
|
|
62
|
+
response.raise_for_status()
|
|
63
|
+
content = response.content
|
|
64
|
+
if not content:
|
|
65
|
+
msg = f"Failed to download package: no content received from {url}"
|
|
66
|
+
raise ValueError(msg)
|
|
67
|
+
except niquests.RequestException as e:
|
|
68
|
+
logger.error("Failed to download package %s:%s: %s", name, version, e)
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
logger.info("Extracting %s to %s...", name, dest_dir)
|
|
72
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
dest_abs = dest_dir.resolve()
|
|
74
|
+
|
|
75
|
+
tar_data = io.BytesIO(content)
|
|
76
|
+
with tarfile.open(fileobj=tar_data, mode="r:gz") as tar:
|
|
77
|
+
if sys.version_info >= (3, 12):
|
|
78
|
+
tar.extractall(path=dest_dir, filter="data")
|
|
79
|
+
else:
|
|
80
|
+
for member in tar.getmembers():
|
|
81
|
+
member_path = (dest_dir / member.name).resolve()
|
|
82
|
+
if dest_abs not in member_path.parents and member_path != dest_abs:
|
|
83
|
+
msg = f"Attempted directory traversal in tarball: {member.name}"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
tar.extractall(path=dest_dir)
|
|
86
|
+
|
|
87
|
+
logger.info("Successfully vendored %s:%s", name, version)
|
|
88
|
+
return True
|