Compare commits
46 Commits
Author | SHA1 | Date | |
---|---|---|---|
75c37b4aa7 | |||
ef7160fe7c | |||
a6c839a31e | |||
ec401c7d6a | |||
7a5bed0454 | |||
d639b868a1 | |||
ddf509e9a4 | |||
fbb38a9d7d | |||
f0ab45f0c6 | |||
3eb5fb3d75 | |||
f1352658ae | |||
b8b81825f6 | |||
daedacb35f | |||
09a7d38bc7 | |||
ff803dbc31 | |||
5ba06140dc | |||
302258ce6c | |||
5423c04df6 | |||
30801c5927 | |||
8b9ff334a5 | |||
08773d61b7 | |||
6726931916 | |||
b0e327e2cd | |||
ab1f25304b | |||
dfc12ed79e | |||
de7fe72cec | |||
0f46808403 | |||
face8e9af0 | |||
d555284a01 | |||
869b0b25b4 | |||
d6c0673a1d | |||
d48daaab10 | |||
e147fad63c | |||
ab0603d1b9 | |||
e6a269af3d | |||
e92283b4e9 | |||
10849adfb8 | |||
8a4ac73c8d | |||
fe0d9059aa | |||
1b367f5ddb | |||
7ff461fd89 | |||
8585380eae | |||
d876639c3e | |||
a27e09c77e | |||
3f23ddd3cc | |||
1b74126494 |
65
.drone.star
65
.drone.star
|
@ -1,9 +1,12 @@
|
||||||
# Build pipelines
|
# Build pipelines
|
||||||
|
|
||||||
PYTHON_VERSIONS = [
|
PYTHON_VERSIONS = [
|
||||||
# "3.7", doesnt support subscripted types, eg list[str]
|
"3.7",
|
||||||
# "3.8",
|
"3.8",
|
||||||
"3.9",
|
"3.9",
|
||||||
|
"3.10",
|
||||||
|
"3.11",
|
||||||
|
"3.12",
|
||||||
"latest",
|
"latest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -13,6 +16,19 @@ def main(ctx):
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
pipelines += tests()
|
pipelines += tests()
|
||||||
|
pipelines += [{
|
||||||
|
"kind": "pipeline",
|
||||||
|
"name": "lint",
|
||||||
|
"workspace": get_workspace(),
|
||||||
|
"steps": [{
|
||||||
|
"name": "lint",
|
||||||
|
"image": "python:3",
|
||||||
|
"commands": [
|
||||||
|
"python -V",
|
||||||
|
"make lint",
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
|
||||||
# Add pypi push pipeline
|
# Add pypi push pipeline
|
||||||
pipelines += push_to_pypi()
|
pipelines += push_to_pypi()
|
||||||
|
@ -41,24 +57,20 @@ def tests():
|
||||||
"name": "tests",
|
"name": "tests",
|
||||||
"workspace": get_workspace(),
|
"workspace": get_workspace(),
|
||||||
"steps": [
|
"steps": [
|
||||||
tox_step("python:"+version)
|
test_step("python:"+version)
|
||||||
for version in PYTHON_VERSIONS
|
for version in PYTHON_VERSIONS
|
||||||
],
|
],
|
||||||
}]
|
}]
|
||||||
|
|
||||||
|
|
||||||
# Builds a single python test step
|
# Builds a single python test step
|
||||||
def tox_step(docker_tag, python_cmd="python", tox_env="py3"):
|
def test_step(docker_tag, python_cmd="python"):
|
||||||
return {
|
return {
|
||||||
"name": "test {}".format(docker_tag.replace(":", "")),
|
"name": "test {}".format(docker_tag.replace(":", "")),
|
||||||
"image": docker_tag,
|
"image": docker_tag,
|
||||||
"environment": {
|
|
||||||
"TOXENV": tox_env,
|
|
||||||
},
|
|
||||||
"commands": [
|
"commands": [
|
||||||
"{} -V".format(python_cmd),
|
"{} -V".format(python_cmd),
|
||||||
"pip install tox",
|
"make clean-all test"
|
||||||
"tox",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,37 +120,36 @@ def push_to_pypi():
|
||||||
return [{
|
return [{
|
||||||
"kind": "pipeline",
|
"kind": "pipeline",
|
||||||
"name": "deploy to pypi",
|
"name": "deploy to pypi",
|
||||||
"depends_on": ["tests"],
|
"depends_on": ["tests", "lint"],
|
||||||
"workspace": get_workspace(),
|
"workspace": get_workspace(),
|
||||||
"trigger": {
|
"trigger": {
|
||||||
"event": ["tag"],
|
|
||||||
"ref": [
|
"ref": [
|
||||||
"refs/heads/main",
|
# "refs/heads/main",
|
||||||
"refs/tags/v*",
|
"refs/tags/v*",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"steps": [
|
"steps": [
|
||||||
{
|
# {
|
||||||
"name": "push to test pypi",
|
# "name": "push to test pypi",
|
||||||
"image": "python:3",
|
# "image": "python:3",
|
||||||
"environment": {
|
# "environment": {
|
||||||
"TWINE_USERNAME": {
|
# "HATCH_INDEX_USER": {
|
||||||
"from_secret": "PYPI_USERNAME",
|
# "from_secret": "PYPI_USERNAME",
|
||||||
},
|
# },
|
||||||
"TWINE_PASSWORD": {
|
# "HATCH_INDEX_AUTH": {
|
||||||
"from_secret": "TEST_PYPI_PASSWORD",
|
# "from_secret": "TEST_PYPI_PASSWORD",
|
||||||
},
|
# },
|
||||||
},
|
# },
|
||||||
"commands": ["make upload-test"],
|
# "commands": ["make upload-test"],
|
||||||
},
|
# },
|
||||||
{
|
{
|
||||||
"name": "push to pypi",
|
"name": "push to pypi",
|
||||||
"image": "python:3",
|
"image": "python:3",
|
||||||
"environment": {
|
"environment": {
|
||||||
"TWINE_USERNAME": {
|
"HATCH_INDEX_USER": {
|
||||||
"from_secret": "PYPI_USERNAME",
|
"from_secret": "PYPI_USERNAME",
|
||||||
},
|
},
|
||||||
"TWINE_PASSWORD": {
|
"HATCH_INDEX_AUTH": {
|
||||||
"from_secret": "PYPI_PASSWORD",
|
"from_secret": "PYPI_PASSWORD",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
---
|
---
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 22.3.0
|
rev: 24.4.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.1.0
|
rev: v4.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- id: check-merge-conflict
|
- id: check-merge-conflict
|
||||||
|
@ -14,12 +14,12 @@ repos:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: name-tests-test
|
- id: name-tests-test
|
||||||
exclude: tests/(common.py|util.py|(helpers|integration/factories)/(.+).py)
|
exclude: tests/(common.py|util.py|(helpers|integration/factories)/(.+).py)
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: v3.0.1
|
rev: 5.13.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: isort
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v0.942
|
rev: v1.10.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
exclude: docs/
|
exclude: docs/
|
||||||
|
|
78
Makefile
78
Makefile
|
@ -5,7 +5,7 @@ ENV := venv
|
||||||
.PHONY: default
|
.PHONY: default
|
||||||
default: test
|
default: test
|
||||||
|
|
||||||
# Creates virtualenv
|
# Creates de virtualenv
|
||||||
$(ENV):
|
$(ENV):
|
||||||
python3 -m venv $(ENV)
|
python3 -m venv $(ENV)
|
||||||
|
|
||||||
|
@ -13,86 +13,76 @@ $(ENV):
|
||||||
$(ENV)/bin/$(NAME): $(ENV)
|
$(ENV)/bin/$(NAME): $(ENV)
|
||||||
$(ENV)/bin/pip install -r requirements-dev.txt
|
$(ENV)/bin/pip install -r requirements-dev.txt
|
||||||
|
|
||||||
# Install tox into virtualenv for running tests
|
# Install hatch into virtualenv for running tests
|
||||||
$(ENV)/bin/tox: $(ENV)
|
$(ENV)/bin/hatch: $(ENV)
|
||||||
$(ENV)/bin/pip install tox
|
$(ENV)/bin/pip install hatch
|
||||||
|
|
||||||
# Install wheel for building packages
|
|
||||||
$(ENV)/bin/wheel: $(ENV)
|
|
||||||
$(ENV)/bin/pip install wheel
|
|
||||||
|
|
||||||
# Install twine for uploading packages
|
|
||||||
$(ENV)/bin/twine: $(ENV)
|
|
||||||
$(ENV)/bin/pip install twine
|
|
||||||
|
|
||||||
# Installs dev requirements to virtualenv
|
# Installs dev requirements to virtualenv
|
||||||
.PHONY: devenv
|
.PHONY: devenv
|
||||||
devenv: $(ENV)/bin/$(NAME)
|
devenv: $(ENV)/bin/$(NAME)
|
||||||
|
|
||||||
# Generates a smaller env for running tox, which builds it's own env
|
# Runs tests for current python
|
||||||
.PHONY: test-env
|
|
||||||
test-env: $(ENV)/bin/tox
|
|
||||||
|
|
||||||
# Generates a small build env for building and uploading dists
|
|
||||||
.PHONY: build-env
|
|
||||||
build-env: $(ENV)/bin/twine $(ENV)/bin/wheel
|
|
||||||
|
|
||||||
# Runs package
|
|
||||||
.PHONY: run
|
|
||||||
run: $(ENV)/bin/$(NAME)
|
|
||||||
$(ENV)/bin/$(NAME)
|
|
||||||
|
|
||||||
# Runs tests with tox
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: $(ENV)/bin/tox
|
test: $(ENV)/bin/hatch
|
||||||
$(ENV)/bin/tox
|
$(ENV)/bin/hatch run +py=3 test:run
|
||||||
|
|
||||||
|
# Runs test matrix
|
||||||
|
.PHONY: test-matrix
|
||||||
|
test-matrix: $(ENV)/bin/hatch
|
||||||
|
$(ENV)/bin/hatch run test:run
|
||||||
|
|
||||||
# Builds wheel for package to upload
|
# Builds wheel for package to upload
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: $(ENV)/bin/wheel
|
build: $(ENV)/bin/hatch
|
||||||
$(ENV)/bin/python setup.py sdist
|
$(ENV)/bin/hatch build
|
||||||
$(ENV)/bin/python setup.py bdist_wheel
|
|
||||||
|
|
||||||
# Verify that the python version matches the git tag so we don't push bad shas
|
# Verify that the python version matches the git tag so we don't push bad shas
|
||||||
.PHONY: verify-tag-version
|
.PHONY: verify-tag-version
|
||||||
verify-tag-version:
|
verify-tag-version: $(ENV)/bin/hatch
|
||||||
$(eval TAG_NAME = $(shell [ -n "$(DRONE_TAG)" ] && echo $(DRONE_TAG) || git describe --tags --exact-match))
|
$(eval TAG_NAME = $(shell [ -n "$(DRONE_TAG)" ] && echo $(DRONE_TAG) || git describe --tags --exact-match))
|
||||||
test "v$(shell python setup.py -V)" = "$(TAG_NAME)"
|
test "v$(shell $(ENV)/bin/hatch version)" = "$(TAG_NAME)"
|
||||||
|
|
||||||
# Uses twine to upload to pypi
|
# Upload to pypi
|
||||||
.PHONY: upload
|
.PHONY: upload
|
||||||
upload: verify-tag-version build $(ENV)/bin/twine
|
upload: verify-tag-version build
|
||||||
$(ENV)/bin/twine upload dist/*
|
$(ENV)/bin/hatch publish
|
||||||
|
|
||||||
# Uses twine to upload to test pypi
|
# Uses twine to upload to test pypi
|
||||||
.PHONY: upload-test
|
.PHONY: upload-test
|
||||||
upload-test: verify-tag-version build $(ENV)/bin/twine
|
upload-test: build
|
||||||
$(ENV)/bin/twine upload --repository-url https://test.pypi.org/legacy/ dist/*
|
# Bump version to a post version based on num of commits since last tag to prevent overwriting
|
||||||
|
$(ENV)/bin/hatch version $(shell git describe --tags | sed 's/-[0-9a-z]*$$//')
|
||||||
|
$(ENV)/bin/hatch publish --repo test
|
||||||
|
|
||||||
# Cleans all build, runtime, and test artifacts
|
# Cleans all build, runtime, and test artifacts
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
clean:
|
clean:
|
||||||
rm -fr ./build *.egg-info ./htmlcov ./.coverage ./.pytest_cache ./.tox
|
rm -fr ./build *.egg-info ./htmlcov ./.coverage ./.pytest_cache
|
||||||
find . -name '*.pyc' -delete
|
find . -name '*.pyc' -delete
|
||||||
find . -name '__pycache__' -delete
|
find . -name '__pycache__' -delete
|
||||||
|
|
||||||
# Cleans dist and env
|
# Cleans dist and env
|
||||||
.PHONY: dist-clean
|
.PHONY: dist-clean
|
||||||
dist-clean: clean
|
dist-clean: clean
|
||||||
|
-$(ENV)/bin/hatch env prune
|
||||||
rm -fr ./dist $(ENV)
|
rm -fr ./dist $(ENV)
|
||||||
|
|
||||||
|
# Run linters
|
||||||
|
.PHONY: lint
|
||||||
|
lint: $(ENV)/bin/hatch
|
||||||
|
$(ENV)/bin/hatch run lint:all
|
||||||
|
|
||||||
# Install pre-commit hooks
|
# Install pre-commit hooks
|
||||||
.PHONY: install-hooks
|
.PHONY: install-hooks
|
||||||
install-hooks: devenv
|
install-hooks: devenv
|
||||||
$(ENV)/bin/pre-commit install -f --install-hooks
|
$(ENV)/bin/hatch run lint:install-hooks
|
||||||
|
|
||||||
# Generates test coverage
|
# Generates test coverage
|
||||||
.coverage:
|
.coverage: test
|
||||||
$(ENV)/bin/tox
|
|
||||||
|
|
||||||
# Builds coverage html
|
# Builds coverage html
|
||||||
htmlcov/index.html: .coverage
|
htmlcov/index.html: .coverage
|
||||||
$(ENV)/bin/coverage html
|
$(ENV)/bin/hatch run coverage html
|
||||||
|
|
||||||
# Opens coverage html in browser (on macOS and some Linux systems)
|
# Opens coverage html in browser (on macOS and some Linux systems)
|
||||||
.PHONY: open-coverage
|
.PHONY: open-coverage
|
||||||
|
@ -106,7 +96,7 @@ docs-clean:
|
||||||
|
|
||||||
# Builds docs
|
# Builds docs
|
||||||
docs/build/html/index.html:
|
docs/build/html/index.html:
|
||||||
$(ENV)/bin/tox -e docs
|
$(ENV)/bin/hatch run docs:build
|
||||||
|
|
||||||
# Shorthand for building docs
|
# Shorthand for building docs
|
||||||
.PHONY: docs
|
.PHONY: docs
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
# sys.path.insert(0, os.path.abspath('.'))
|
# sys.path.insert(0, os.path.abspath('.'))
|
||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
project = "release-gitter"
|
project = "release-gitter"
|
||||||
copyright = "2021, iamthefij"
|
copyright = "2021, iamthefij"
|
||||||
author = "iamthefij"
|
author = "iamthefij"
|
||||||
|
|
|
@ -2,19 +2,44 @@
|
||||||
This builder functions as a pseudo builder that instead downloads and installs a binary file using
|
This builder functions as a pseudo builder that instead downloads and installs a binary file using
|
||||||
release-gitter based on a pyproject.toml file. It's a total hack...
|
release-gitter based on a pyproject.toml file. It's a total hack...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from shutil import copy
|
||||||
from shutil import copytree
|
from shutil import copytree
|
||||||
|
from shutil import move
|
||||||
|
|
||||||
import toml
|
import toml
|
||||||
from wheel.wheelfile import WheelFile
|
from wheel.wheelfile import WheelFile
|
||||||
|
|
||||||
import release_gitter as rg
|
import release_gitter as rg
|
||||||
|
from release_gitter import removeprefix
|
||||||
|
|
||||||
PACKAGE_NAME = "pseudo"
|
PACKAGE_NAME = "pseudo"
|
||||||
|
|
||||||
|
|
||||||
def download(config) -> list[Path]:
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
format: str
|
||||||
|
git_url: str
|
||||||
|
hostname: str
|
||||||
|
owner: str
|
||||||
|
repo: str
|
||||||
|
version: str | None = None
|
||||||
|
pre_release: bool = False
|
||||||
|
version_git_tag: bool = False
|
||||||
|
version_git_no_fetch: bool = False
|
||||||
|
map_system: dict[str, str] | None = None
|
||||||
|
map_arch: dict[str, str] | None = None
|
||||||
|
exec: str | None = None
|
||||||
|
extract_all: bool = False
|
||||||
|
extract_files: list[str] | None = None
|
||||||
|
include_extra_files: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def download(config: Config) -> list[Path]:
|
||||||
release = rg.fetch_release(
|
release = rg.fetch_release(
|
||||||
rg.GitRemoteInfo(config.hostname, config.owner, config.repo), config.version
|
rg.GitRemoteInfo(config.hostname, config.owner, config.repo), config.version
|
||||||
)
|
)
|
||||||
|
@ -35,26 +60,35 @@ def download(config) -> list[Path]:
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
def read_metadata():
|
def read_metadata() -> Config:
|
||||||
config = toml.load("pyproject.toml").get("tool", {}).get("release-gitter")
|
config = toml.load("pyproject.toml").get("tool", {}).get("release-gitter")
|
||||||
if not config:
|
if not config:
|
||||||
raise ValueError("Must have configuration in [tool.release-gitter]")
|
raise ValueError("Must have configuration in [tool.release-gitter]")
|
||||||
|
|
||||||
args = []
|
git_url = config.pop("git-url", None)
|
||||||
for key, value in config.items():
|
remote_info = rg.parse_git_remote(git_url)
|
||||||
key = "--" + key
|
|
||||||
if key == "--format":
|
|
||||||
args += [value]
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
for sub_key, sub_value in value.items():
|
|
||||||
args = [key, f"{sub_key}={sub_value}"] + args
|
|
||||||
elif isinstance(value, list):
|
|
||||||
for sub_value in value:
|
|
||||||
args = [key, sub_value] + args
|
|
||||||
else:
|
|
||||||
args = [key, value] + args
|
|
||||||
|
|
||||||
return rg._parse_args(args)
|
args = Config(
|
||||||
|
format=config.pop("format"),
|
||||||
|
git_url=git_url,
|
||||||
|
hostname=config.pop("hostname", remote_info.hostname),
|
||||||
|
owner=config.pop("owner", remote_info.owner),
|
||||||
|
repo=config.pop("repo", remote_info.repo),
|
||||||
|
)
|
||||||
|
|
||||||
|
for key, value in config.items():
|
||||||
|
setattr(args, str(key).replace("-", "_"), value)
|
||||||
|
|
||||||
|
if args.version is None:
|
||||||
|
args.version = rg.read_version(
|
||||||
|
args.version_git_tag,
|
||||||
|
not args.version_git_no_fetch,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.extract_all:
|
||||||
|
args.extract_files = []
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
class _PseudoBuildBackend:
|
class _PseudoBuildBackend:
|
||||||
|
@ -64,11 +98,11 @@ class _PseudoBuildBackend:
|
||||||
def prepare_metadata_for_build_wheel(
|
def prepare_metadata_for_build_wheel(
|
||||||
self, metadata_directory, config_settings=None
|
self, metadata_directory, config_settings=None
|
||||||
):
|
):
|
||||||
# Createa .dist-info directory containing wheel metadata inside metadata_directory. Eg {metadata_directory}/{package}-{version}.dist-info/
|
# Create a .dist-info directory containing wheel metadata inside metadata_directory. Eg {metadata_directory}/{package}-{version}.dist-info/
|
||||||
print("Prepare meta", metadata_directory, config_settings)
|
print("Prepare meta", metadata_directory, config_settings)
|
||||||
|
|
||||||
metadata = read_metadata()
|
metadata = read_metadata()
|
||||||
version = metadata.version.removeprefix("v")
|
version = removeprefix(metadata.version, "v") if metadata.version else "0.0.0"
|
||||||
|
|
||||||
# Returns distinfo dir?
|
# Returns distinfo dir?
|
||||||
dist_info = Path(metadata_directory) / f"{PACKAGE_NAME}-{version}.dist-info"
|
dist_info = Path(metadata_directory) / f"{PACKAGE_NAME}-{version}.dist-info"
|
||||||
|
@ -115,7 +149,7 @@ class _PseudoBuildBackend:
|
||||||
metadata_directory = Path(metadata_directory)
|
metadata_directory = Path(metadata_directory)
|
||||||
|
|
||||||
metadata = read_metadata()
|
metadata = read_metadata()
|
||||||
version = metadata.version.removeprefix("v")
|
version = removeprefix(metadata.version, "v") if metadata.version else "0.0.0"
|
||||||
|
|
||||||
wheel_directory = Path(wheel_directory)
|
wheel_directory = Path(wheel_directory)
|
||||||
wheel_directory.mkdir(exist_ok=True)
|
wheel_directory.mkdir(exist_ok=True)
|
||||||
|
@ -123,15 +157,23 @@ class _PseudoBuildBackend:
|
||||||
wheel_scripts = wheel_directory / f"{PACKAGE_NAME}-{version}.data/scripts"
|
wheel_scripts = wheel_directory / f"{PACKAGE_NAME}-{version}.data/scripts"
|
||||||
wheel_scripts.mkdir(parents=True, exist_ok=True)
|
wheel_scripts.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# copytree(metadata_directory, wheel_directory / metadata_directory.name)
|
|
||||||
copytree(metadata_directory, wheel_directory / metadata_directory.name)
|
copytree(metadata_directory, wheel_directory / metadata_directory.name)
|
||||||
|
|
||||||
metadata = read_metadata()
|
metadata = read_metadata()
|
||||||
files = download(metadata)
|
files = download(metadata)
|
||||||
for file in files:
|
for file in files:
|
||||||
file.rename(wheel_scripts / file.name)
|
move(file, wheel_scripts / file.name)
|
||||||
|
|
||||||
print(f"ls {wheel_directory}: {list(wheel_directory.glob('*'))}")
|
for file_name in metadata.include_extra_files or []:
|
||||||
|
file = Path(file_name)
|
||||||
|
if Path.cwd() in file.absolute().parents:
|
||||||
|
copy(file_name, wheel_scripts / file)
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cannot include any path that is not within the current directory: {file_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"ls {wheel_directory}: {list(wheel_directory.rglob('*'))}")
|
||||||
|
|
||||||
wheel_filename = f"{PACKAGE_NAME}-{version}-py2.py3-none-any.whl"
|
wheel_filename = f"{PACKAGE_NAME}-{version}-py2.py3-none-any.whl"
|
||||||
with WheelFile(wheel_directory / wheel_filename, "w") as wf:
|
with WheelFile(wheel_directory / wheel_filename, "w") as wf:
|
||||||
|
|
69
pyproject.toml
Normal file
69
pyproject.toml
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "release-gitter"
|
||||||
|
dynamic = ["version"]
|
||||||
|
description = "Easily download releases from sites like Github and Gitea"
|
||||||
|
readme = "README.md"
|
||||||
|
license = "MIT"
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
]
|
||||||
|
authors = [
|
||||||
|
{ name = "Ian Fijolek", email = "iamthefij@gmail.com" }
|
||||||
|
]
|
||||||
|
maintainers = [
|
||||||
|
{ name = "Ian Fijolek", email = "iamthefij@gmail.com" }
|
||||||
|
]
|
||||||
|
requires-python = ">=3.7"
|
||||||
|
dependencies = ["requests"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
builder = [
|
||||||
|
"toml",
|
||||||
|
"wheel",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
release-gitter = "release_gitter:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://git.iamthefij.com/iamthefij/release-gitter"
|
||||||
|
|
||||||
|
[tool.hatch.version]
|
||||||
|
path = "release_gitter.py"
|
||||||
|
|
||||||
|
[tool.hatch.build]
|
||||||
|
include = ["release_gitter.py", "pseudo_builder.py"]
|
||||||
|
|
||||||
|
[tool.hatch.envs.test]
|
||||||
|
dependencies = [
|
||||||
|
"coverage",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.envs.test.scripts]
|
||||||
|
run = [
|
||||||
|
"coverage erase",
|
||||||
|
"coverage run --source=release_gitter -m unittest discover . *_test.py",
|
||||||
|
"coverage report -m # --fail-under 70",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tool.hatch.envs.test.matrix]]
|
||||||
|
python = ["3", "3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
|
[tool.hatch.envs.lint]
|
||||||
|
detached = true
|
||||||
|
dependencies = ["pre-commit"]
|
||||||
|
|
||||||
|
[tool.hatch.envs.lint.scripts]
|
||||||
|
all = "pre-commit run --all-files"
|
||||||
|
install-hooks = "pre-commit install --install-hooks"
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
add_imports = ["from __future__ import annotations"]
|
||||||
|
force_single_line = true
|
||||||
|
profile = "black"
|
|
@ -1,9 +1,12 @@
|
||||||
#! /usr/bin/env python3
|
#! /usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import platform
|
import platform
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from itertools import product
|
||||||
from mimetypes import guess_type
|
from mimetypes import guess_type
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import check_call
|
from subprocess import check_call
|
||||||
|
@ -11,23 +14,69 @@ from subprocess import check_output
|
||||||
from tarfile import TarFile
|
from tarfile import TarFile
|
||||||
from tarfile import TarInfo
|
from tarfile import TarInfo
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Optional
|
|
||||||
from typing import Union
|
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
__version__ = "2.3.0"
|
||||||
|
|
||||||
# Extract metadata from repo
|
|
||||||
|
class UnsupportedContentTypeError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidRemoteError(ValueError):
|
class InvalidRemoteError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def removeprefix(s: str, pre: str) -> str:
|
||||||
|
# Duplicate str.removeprefix for py<3.9
|
||||||
|
try:
|
||||||
|
return s.removeprefix(pre) # type: ignore
|
||||||
|
except AttributeError:
|
||||||
|
# Py < 3.9
|
||||||
|
return s[len(pre) :] if s and s.startswith(pre) else s
|
||||||
|
|
||||||
|
|
||||||
|
def removesuffix(s: str, suf: str) -> str:
|
||||||
|
# Duplicate str.removesuffix for py<3.9
|
||||||
|
try:
|
||||||
|
return s.removesuffix(suf) # type: ignore
|
||||||
|
except AttributeError:
|
||||||
|
# Py < 3.9
|
||||||
|
return s[: -len(suf)] if s and s.endswith(suf) else s
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEM_SYNONYMS: list[list[str]] = [
|
||||||
|
["Darwin", "darwin", "MacOS", "macos", "macOS"],
|
||||||
|
["Windows", "windows", "win", "win32", "win64"],
|
||||||
|
["Linux", "linux"],
|
||||||
|
]
|
||||||
|
|
||||||
|
ARCH_SYNONYMS: list[list[str]] = [
|
||||||
|
["arm"],
|
||||||
|
["x86_64", "amd64", "AMD64"],
|
||||||
|
["arm64", "aarch64", "armv8b", "armv8l"],
|
||||||
|
["x86", "i386", "i686"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_synonyms(value: str, thesaurus: list[list[str]]) -> list[str]:
|
||||||
|
"""Gets synonym list for a given value."""
|
||||||
|
results = [value]
|
||||||
|
|
||||||
|
for l in thesaurus:
|
||||||
|
if value in l:
|
||||||
|
results += l
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GitRemoteInfo:
|
class GitRemoteInfo:
|
||||||
|
"""Extracts information about a repository"""
|
||||||
|
|
||||||
hostname: str
|
hostname: str
|
||||||
owner: str
|
owner: str
|
||||||
repo: str
|
repo: str
|
||||||
|
@ -65,7 +114,7 @@ class GitRemoteInfo:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_git_remote(git_url: Optional[str] = None) -> GitRemoteInfo:
|
def parse_git_remote(git_url: str | None = None) -> GitRemoteInfo:
|
||||||
"""Extract Github repo info from a git remote url"""
|
"""Extract Github repo info from a git remote url"""
|
||||||
if not git_url:
|
if not git_url:
|
||||||
git_url = (
|
git_url = (
|
||||||
|
@ -91,7 +140,7 @@ def parse_git_remote(git_url: Optional[str] = None) -> GitRemoteInfo:
|
||||||
f"{path[1:3]} Could not parse owner and repo from URL {git_url}"
|
f"{path[1:3]} Could not parse owner and repo from URL {git_url}"
|
||||||
)
|
)
|
||||||
|
|
||||||
return GitRemoteInfo(u.hostname, path[1], path[2].removesuffix(".git"))
|
return GitRemoteInfo(u.hostname, path[1], removesuffix(path[2], ".git"))
|
||||||
|
|
||||||
|
|
||||||
def parse_cargo_version(p: Path) -> str:
|
def parse_cargo_version(p: Path) -> str:
|
||||||
|
@ -104,7 +153,7 @@ def parse_cargo_version(p: Path) -> str:
|
||||||
raise ValueError(f"No version found in {p}")
|
raise ValueError(f"No version found in {p}")
|
||||||
|
|
||||||
|
|
||||||
def read_git_tag(fetch: bool = True) -> Optional[str]:
|
def read_git_tag(fetch: bool = True) -> str | None:
|
||||||
"""Get local git tag for current repo
|
"""Get local git tag for current repo
|
||||||
|
|
||||||
fetch: optionally fetch tags with depth of 1 from remote"""
|
fetch: optionally fetch tags with depth of 1 from remote"""
|
||||||
|
@ -115,7 +164,7 @@ def read_git_tag(fetch: bool = True) -> Optional[str]:
|
||||||
return git_tag or None
|
return git_tag or None
|
||||||
|
|
||||||
|
|
||||||
def read_version(from_tags: bool = False, fetch: bool = False) -> Optional[str]:
|
def read_version(from_tags: bool = False, fetch: bool = False) -> str | None:
|
||||||
"""Read version information from file or from git"""
|
"""Read version information from file or from git"""
|
||||||
if from_tags:
|
if from_tags:
|
||||||
return read_git_tag(fetch)
|
return read_git_tag(fetch)
|
||||||
|
@ -134,13 +183,10 @@ def read_version(from_tags: bool = False, fetch: bool = False) -> Optional[str]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Fetch release and assets from Github
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_release(
|
def fetch_release(
|
||||||
remote: GitRemoteInfo,
|
remote: GitRemoteInfo,
|
||||||
version: Optional[str] = None
|
version: str | None = None,
|
||||||
# TODO: Accept an argument for pre-release
|
pre_release=False,
|
||||||
) -> dict[Any, Any]:
|
) -> dict[Any, Any]:
|
||||||
"""Fetches a release object from a Github repo
|
"""Fetches a release object from a Github repo
|
||||||
|
|
||||||
|
@ -156,22 +202,29 @@ def fetch_release(
|
||||||
|
|
||||||
# Return the latest if requested
|
# Return the latest if requested
|
||||||
if version is None or version == "latest":
|
if version is None or version == "latest":
|
||||||
return result.json()[0]
|
for release in result.json():
|
||||||
|
if release["prerelease"] and not pre_release:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return release
|
||||||
|
|
||||||
# Return matching version
|
# Return matching version
|
||||||
for release in result.json():
|
for release in result.json():
|
||||||
if release["tag_name"].endswith(version):
|
if release["tag_name"].endswith(version):
|
||||||
return release
|
return release
|
||||||
|
|
||||||
raise ValueError(f"Could not find release version ending in {version}")
|
raise ValueError(
|
||||||
|
f"Could not find release version ending in {version}."
|
||||||
|
f"{ ' Is it a pre-release?' if not pre_release else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def match_asset(
|
def match_asset(
|
||||||
release: dict[Any, Any],
|
release: dict[Any, Any],
|
||||||
format: str,
|
format: str,
|
||||||
version: Optional[str] = None,
|
version: str | None = None,
|
||||||
system_mapping: Optional[dict[str, str]] = None,
|
system_mapping: dict[str, str] | None = None,
|
||||||
arch_mapping: Optional[dict[str, str]] = None,
|
arch_mapping: dict[str, str] | None = None,
|
||||||
) -> dict[Any, Any]:
|
) -> dict[Any, Any]:
|
||||||
"""Accepts a release and searches for an appropriate asset attached using
|
"""Accepts a release and searches for an appropriate asset attached using
|
||||||
a provided template and some alternative mappings for version, system, and machine info
|
a provided template and some alternative mappings for version, system, and machine info
|
||||||
|
@ -218,21 +271,29 @@ def match_asset(
|
||||||
|
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
if system_mapping:
|
if system_mapping:
|
||||||
system = system_mapping.get(system, system)
|
systems = [system_mapping.get(system, system)]
|
||||||
|
else:
|
||||||
|
systems = get_synonyms(system, SYSTEM_SYNONYMS)
|
||||||
|
|
||||||
arch = platform.machine()
|
arch = platform.machine()
|
||||||
if arch_mapping:
|
if arch_mapping:
|
||||||
arch = arch_mapping.get(arch, arch)
|
archs = [arch_mapping.get(arch, arch)]
|
||||||
|
else:
|
||||||
|
archs = get_synonyms(arch, ARCH_SYNONYMS)
|
||||||
|
|
||||||
expected_names = {
|
expected_names = {
|
||||||
format.format(
|
format.format(
|
||||||
version=normalized_version,
|
version=version_opt,
|
||||||
system=system,
|
system=system_opt,
|
||||||
arch=arch,
|
arch=arch_opt,
|
||||||
)
|
)
|
||||||
for normalized_version in (
|
for version_opt, system_opt, arch_opt in product(
|
||||||
version.lstrip("v"),
|
(
|
||||||
"v" + version if not version.startswith("v") else version,
|
version.lstrip("v"),
|
||||||
|
"v" + version if not version.startswith("v") else version,
|
||||||
|
),
|
||||||
|
systems,
|
||||||
|
archs,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,19 +310,25 @@ class PackageAdapter:
|
||||||
"""Adapts the names and extractall methods from ZipFile and TarFile classes"""
|
"""Adapts the names and extractall methods from ZipFile and TarFile classes"""
|
||||||
|
|
||||||
def __init__(self, content_type: str, response: requests.Response):
|
def __init__(self, content_type: str, response: requests.Response):
|
||||||
self._package: Union[TarFile, ZipFile]
|
self._package: TarFile | ZipFile
|
||||||
if content_type == "application/zip":
|
if content_type in (
|
||||||
|
"application/zip",
|
||||||
|
"application/x-zip-compressed",
|
||||||
|
):
|
||||||
self._package = ZipFile(BytesIO(response.content))
|
self._package = ZipFile(BytesIO(response.content))
|
||||||
elif content_type == "application/x-tar":
|
elif content_type == "application/x-tar":
|
||||||
self._package = TarFile(fileobj=response.raw)
|
self._package = TarFile(fileobj=response.raw)
|
||||||
elif content_type in (
|
elif content_type in (
|
||||||
"application/gzip",
|
"application/gzip",
|
||||||
"application/x-tar+gzip",
|
"application/x-tar+gzip",
|
||||||
|
"application/x-tar+xz",
|
||||||
"application/x-compressed-tar",
|
"application/x-compressed-tar",
|
||||||
):
|
):
|
||||||
self._package = TarFile.open(fileobj=BytesIO(response.content), mode="r:*")
|
self._package = TarFile.open(fileobj=BytesIO(response.content), mode="r:*")
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown or unsupported content type {content_type}")
|
raise UnsupportedContentTypeError(
|
||||||
|
f"Unknown or unsupported content type {content_type}"
|
||||||
|
)
|
||||||
|
|
||||||
def get_names(self) -> list[str]:
|
def get_names(self) -> list[str]:
|
||||||
"""Get list of all file names in package"""
|
"""Get list of all file names in package"""
|
||||||
|
@ -276,8 +343,8 @@ class PackageAdapter:
|
||||||
|
|
||||||
def extractall(
|
def extractall(
|
||||||
self,
|
self,
|
||||||
path: Optional[Path],
|
path: Path | None,
|
||||||
members: Optional[list[str]],
|
members: list[str] | None,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Extract all or a subset of files from the package
|
"""Extract all or a subset of files from the package
|
||||||
|
|
||||||
|
@ -288,6 +355,10 @@ class PackageAdapter:
|
||||||
self._package.extractall(path=path)
|
self._package.extractall(path=path)
|
||||||
return self.get_names()
|
return self.get_names()
|
||||||
|
|
||||||
|
missing_members = set(members) - set(self.get_names())
|
||||||
|
if missing_members:
|
||||||
|
raise ValueError(f"Missing members: {missing_members}")
|
||||||
|
|
||||||
if isinstance(self._package, ZipFile):
|
if isinstance(self._package, ZipFile):
|
||||||
self._package.extractall(path=path, members=members)
|
self._package.extractall(path=path, members=members)
|
||||||
if isinstance(self._package, TarFile):
|
if isinstance(self._package, TarFile):
|
||||||
|
@ -298,10 +369,31 @@ class PackageAdapter:
|
||||||
return members
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_package(
|
||||||
|
asset: dict[str, Any], result: requests.Response
|
||||||
|
) -> PackageAdapter:
|
||||||
|
possible_content_types = (
|
||||||
|
asset.get("content_type"),
|
||||||
|
"+".join(t for t in guess_type(asset["name"]) if t is not None),
|
||||||
|
)
|
||||||
|
for content_type in possible_content_types:
|
||||||
|
if not content_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
return PackageAdapter(content_type, result)
|
||||||
|
except UnsupportedContentTypeError:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise UnsupportedContentTypeError(
|
||||||
|
"Cannot extract files from archive because we don't recognize the content type"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def download_asset(
|
def download_asset(
|
||||||
asset: dict[Any, Any],
|
asset: dict[Any, Any],
|
||||||
extract_files: Optional[list[str]] = None,
|
extract_files: list[str] | None = None,
|
||||||
destination: Optional[Path] = None,
|
destination: Path | None = None,
|
||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
"""Download asset from entity passed in
|
"""Download asset from entity passed in
|
||||||
|
|
||||||
|
@ -320,18 +412,8 @@ def download_asset(
|
||||||
|
|
||||||
result = requests.get(asset["browser_download_url"])
|
result = requests.get(asset["browser_download_url"])
|
||||||
|
|
||||||
content_type = asset.get(
|
|
||||||
"content_type",
|
|
||||||
guess_type(asset["name"]),
|
|
||||||
)
|
|
||||||
if extract_files is not None:
|
if extract_files is not None:
|
||||||
if isinstance(content_type, tuple):
|
package = get_asset_package(asset, result)
|
||||||
content_type = "+".join(t for t in content_type if t is not None)
|
|
||||||
if not content_type:
|
|
||||||
raise TypeError(
|
|
||||||
"Cannot extract files from archive because we don't recognize the content type"
|
|
||||||
)
|
|
||||||
package = PackageAdapter(content_type, result)
|
|
||||||
extract_files = package.extractall(path=destination, members=extract_files)
|
extract_files = package.extractall(path=destination, members=extract_files)
|
||||||
return [destination / name for name in extract_files]
|
return [destination / name for name in extract_files]
|
||||||
|
|
||||||
|
@ -347,8 +429,8 @@ class MapAddAction(argparse.Action):
|
||||||
self,
|
self,
|
||||||
_: argparse.ArgumentParser,
|
_: argparse.ArgumentParser,
|
||||||
namespace: argparse.Namespace,
|
namespace: argparse.Namespace,
|
||||||
values: Union[str, Sequence[Any], None],
|
values: str | Sequence[Any] | None,
|
||||||
option_string: Optional[str] = None,
|
option_string: str | None = None,
|
||||||
):
|
):
|
||||||
# Validate that required value has something
|
# Validate that required value has something
|
||||||
if self.required and not values:
|
if self.required and not values:
|
||||||
|
@ -378,7 +460,7 @@ class MapAddAction(argparse.Action):
|
||||||
setattr(namespace, self.dest, dest)
|
setattr(namespace, self.dest, dest)
|
||||||
|
|
||||||
|
|
||||||
def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
def _parse_args(args: list[str] | None = None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"format",
|
"format",
|
||||||
|
@ -392,6 +474,7 @@ def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
||||||
default=Path.cwd(),
|
default=Path.cwd(),
|
||||||
help="Destination directory. Defaults to current directory",
|
help="Destination directory. Defaults to current directory",
|
||||||
)
|
)
|
||||||
|
parser.add_argument("-v", action="store_true", help="verbose logging")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--hostname",
|
"--hostname",
|
||||||
help="Git repository hostname",
|
help="Git repository hostname",
|
||||||
|
@ -410,7 +493,12 @@ def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--version",
|
"--version",
|
||||||
help="Release version to download. If not provied, it will look for project metadata",
|
help="Release version to download. If not provided, it will look for project metadata",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prerelease",
|
||||||
|
action="store_true",
|
||||||
|
help="Include pre-release versions in search",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--version-git-tag",
|
"--version-git-tag",
|
||||||
|
@ -444,13 +532,13 @@ def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
||||||
"--extract-files",
|
"--extract-files",
|
||||||
"-e",
|
"-e",
|
||||||
action="append",
|
action="append",
|
||||||
help="A list of file names to extract from downloaded archive",
|
help="A list of file names to extract from the downloaded archive",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--extract-all",
|
"--extract-all",
|
||||||
"-x",
|
"-x",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Shell commands to execute after download or extraction",
|
help="Extract all files from the downloaded archive",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--url-only",
|
"--url-only",
|
||||||
|
@ -488,13 +576,18 @@ def download_release(
|
||||||
remote_info: GitRemoteInfo,
|
remote_info: GitRemoteInfo,
|
||||||
destination: Path,
|
destination: Path,
|
||||||
format: str,
|
format: str,
|
||||||
version: Optional[str] = None,
|
version: str | None = None,
|
||||||
system_mapping: Optional[dict[str, str]] = None,
|
system_mapping: dict[str, str] | None = None,
|
||||||
arch_mapping: Optional[dict[str, str]] = None,
|
arch_mapping: dict[str, str] | None = None,
|
||||||
extract_files: Optional[list[str]] = None,
|
extract_files: list[str] | None = None,
|
||||||
|
pre_release=False,
|
||||||
) -> list[Path]:
|
) -> list[Path]:
|
||||||
"""Convenience method for fetching, downloading and extracting a release"""
|
"""Convenience method for fetching, downloading and extracting a release"""
|
||||||
release = fetch_release(remote_info)
|
release = fetch_release(
|
||||||
|
remote_info,
|
||||||
|
version=version,
|
||||||
|
pre_release=pre_release,
|
||||||
|
)
|
||||||
asset = match_asset(
|
asset = match_asset(
|
||||||
release,
|
release,
|
||||||
format,
|
format,
|
||||||
|
@ -515,7 +608,9 @@ def main():
|
||||||
args = _parse_args()
|
args = _parse_args()
|
||||||
|
|
||||||
release = fetch_release(
|
release = fetch_release(
|
||||||
GitRemoteInfo(args.hostname, args.owner, args.repo), args.version
|
GitRemoteInfo(args.hostname, args.owner, args.repo),
|
||||||
|
version=args.version,
|
||||||
|
pre_release=args.prerelease,
|
||||||
)
|
)
|
||||||
asset = match_asset(
|
asset = match_asset(
|
||||||
release,
|
release,
|
||||||
|
@ -525,6 +620,9 @@ def main():
|
||||||
arch_mapping=args.map_arch,
|
arch_mapping=args.map_arch,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if args.v:
|
||||||
|
print(f"Downloading {asset['name']} from release {release['name']}")
|
||||||
|
|
||||||
if args.url_only:
|
if args.url_only:
|
||||||
print(asset["browser_download_url"])
|
print(asset["browser_download_url"])
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from itertools import chain
|
||||||
|
from itertools import product
|
||||||
|
from tarfile import TarFile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Optional
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
from unittest.mock import mock_open
|
from unittest.mock import mock_open
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
@ -18,10 +22,11 @@ class TestExpression(NamedTuple):
|
||||||
args: list[Any]
|
args: list[Any]
|
||||||
kwargs: dict[str, Any]
|
kwargs: dict[str, Any]
|
||||||
expected: Any
|
expected: Any
|
||||||
exception: Optional[type[Exception]]
|
exception: type[Exception] | None = None
|
||||||
|
msg: str | None = None
|
||||||
|
|
||||||
def run(self, f: Callable):
|
def run(self, f: Callable):
|
||||||
with self.t.subTest(f=f, args=self.args, kwargs=self.kwargs):
|
with self.t.subTest(msg=self.msg, f=f, args=self.args, kwargs=self.kwargs):
|
||||||
try:
|
try:
|
||||||
result = f(*self.args, **self.kwargs)
|
result = f(*self.args, **self.kwargs)
|
||||||
self.t.assertIsNone(
|
self.t.assertIsNone(
|
||||||
|
@ -36,6 +41,15 @@ class TestExpression(NamedTuple):
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeneral(unittest.TestCase):
|
||||||
|
def test_removesuffix(self):
|
||||||
|
for test_case in (
|
||||||
|
TestExpression(self, ["repo.git", ".git"], {}, "repo"),
|
||||||
|
TestExpression(self, ["repo", ".git"], {}, "repo"),
|
||||||
|
):
|
||||||
|
test_case.run(release_gitter.removesuffix)
|
||||||
|
|
||||||
|
|
||||||
class TestRemoteInfo(unittest.TestCase):
|
class TestRemoteInfo(unittest.TestCase):
|
||||||
def test_parse_remote_info(self):
|
def test_parse_remote_info(self):
|
||||||
for test_case in (
|
for test_case in (
|
||||||
|
@ -111,27 +125,350 @@ class TestVersionInfo(unittest.TestCase):
|
||||||
version = release_gitter.read_version()
|
version = release_gitter.read_version()
|
||||||
self.assertIsNone(version)
|
self.assertIsNone(version)
|
||||||
|
|
||||||
def test_cargo_file_has_version(self):
|
@patch("pathlib.Path.exists", return_value=True)
|
||||||
with (
|
@patch(
|
||||||
patch("pathlib.Path.exists", return_value=True),
|
"pathlib.Path.open",
|
||||||
patch(
|
mock_open(read_data="\n".join(["[package]", 'version = "1.0.0"'])),
|
||||||
"pathlib.Path.open",
|
)
|
||||||
mock_open(read_data="\n".join(["[package]", 'version = "1.0.0"'])),
|
def test_cargo_file_has_version(self, *_):
|
||||||
),
|
version = release_gitter.read_version()
|
||||||
):
|
self.assertEqual(version, "1.0.0")
|
||||||
version = release_gitter.read_version()
|
|
||||||
self.assertEqual(version, "1.0.0")
|
|
||||||
|
|
||||||
def test_cargo_file_missing_version(self):
|
@patch("pathlib.Path.exists", return_value=True)
|
||||||
with (
|
@patch(
|
||||||
patch("pathlib.Path.exists", return_value=True),
|
"pathlib.Path.open",
|
||||||
patch(
|
mock_open(read_data="\n".join(["[package]"])),
|
||||||
"pathlib.Path.open",
|
)
|
||||||
mock_open(read_data="\n".join(["[package]"])),
|
def test_cargo_file_missing_version(self, *_):
|
||||||
),
|
with self.assertRaises(ValueError):
|
||||||
|
release_gitter.read_version()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("release_gitter.ZipFile", autospec=True)
|
||||||
|
@patch("release_gitter.BytesIO", autospec=True)
|
||||||
|
class TestContentTypeDetection(unittest.TestCase):
|
||||||
|
def test_asset_encoding_priority(self, *_):
|
||||||
|
package = release_gitter.get_asset_package(
|
||||||
|
{
|
||||||
|
"content_type": "application/x-tar",
|
||||||
|
"name": "test.zip",
|
||||||
|
},
|
||||||
|
MagicMock(spec=["raw", "content"]),
|
||||||
|
)
|
||||||
|
# Tar should take priority over the file name zip extension
|
||||||
|
self.assertIsInstance(package._package, TarFile)
|
||||||
|
|
||||||
|
def test_fallback_to_supported_encoding(self, *_):
|
||||||
|
package = release_gitter.get_asset_package(
|
||||||
|
{
|
||||||
|
"content_type": "application/octetstream",
|
||||||
|
"name": "test.zip",
|
||||||
|
},
|
||||||
|
MagicMock(spec=["raw", "content"]),
|
||||||
|
)
|
||||||
|
# Should fall back to zip extension
|
||||||
|
self.assertIsInstance(package._package, ZipFile)
|
||||||
|
|
||||||
|
def test_missing_only_name_content_type(self, *_):
|
||||||
|
package = release_gitter.get_asset_package(
|
||||||
|
{
|
||||||
|
"name": "test.zip",
|
||||||
|
},
|
||||||
|
MagicMock(spec=["raw", "content"]),
|
||||||
|
)
|
||||||
|
# Should fall back to zip extension
|
||||||
|
self.assertIsInstance(package._package, ZipFile)
|
||||||
|
|
||||||
|
def test_no_content_types(self, *_):
|
||||||
|
with self.assertRaises(release_gitter.UnsupportedContentTypeError):
|
||||||
|
release_gitter.get_asset_package(
|
||||||
|
{
|
||||||
|
"name": "test",
|
||||||
|
},
|
||||||
|
MagicMock(spec=["raw", "content"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_supported_content_types(self, *_):
|
||||||
|
with self.assertRaises(release_gitter.UnsupportedContentTypeError):
|
||||||
|
release_gitter.get_asset_package(
|
||||||
|
{
|
||||||
|
"content_type": "application/octetstream",
|
||||||
|
"name": "test",
|
||||||
|
},
|
||||||
|
MagicMock(spec=["raw", "content"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchAsset(unittest.TestCase):
|
||||||
|
def test_match_asset_versions(self, *_):
|
||||||
|
# Input variations:
|
||||||
|
# Case 1: Version provided with prefix
|
||||||
|
# Case 2: Version provided without prefix
|
||||||
|
# Case 3: No version provided, tag exists in release
|
||||||
|
# These should be impossible
|
||||||
|
# Case 4: No version provided, tag doesn't exist in release but not in template
|
||||||
|
# Case 5: No version provided, tag doesn't exist in release and is in template
|
||||||
|
|
||||||
|
# Release variations:
|
||||||
|
# Case 1: tag_name with version prefix
|
||||||
|
# Case 2: tag_name without version prefix
|
||||||
|
|
||||||
|
# File variations:
|
||||||
|
# Case 1: file name with version prefix
|
||||||
|
# Case 2: file name without version prefix
|
||||||
|
|
||||||
|
def new_expression(version: str | None, tag_name: str, file_name: str):
|
||||||
|
release = {"tag_name": tag_name, "assets": [{"name": file_name}]}
|
||||||
|
expected = {"name": file_name}
|
||||||
|
return TestExpression(
|
||||||
|
self, [release, "file-{version}.zip", version], {}, expected
|
||||||
|
)
|
||||||
|
|
||||||
|
happy_cases = [
|
||||||
|
new_expression(version, tag_name, file_name)
|
||||||
|
for version, tag_name, file_name in product(
|
||||||
|
("v1.0.0", "1.0.0", None),
|
||||||
|
("v1.0.0", "1.0.0"),
|
||||||
|
("file-v1.0.0.zip", "file-1.0.0.zip"),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for test_case in happy_cases:
|
||||||
|
test_case.run(release_gitter.match_asset)
|
||||||
|
|
||||||
|
def test_match_asset_systems(self, *_):
|
||||||
|
# Input variations:
|
||||||
|
# Case 1: System mapping provided
|
||||||
|
# Case 2: No system mapping provided
|
||||||
|
|
||||||
|
# Test: We want to show that default matching will work out of the box with some values for the current machine
|
||||||
|
# Test: We want to show that non-standard mappings will always work if provided manually
|
||||||
|
|
||||||
|
def run_with_context(actual_system: str, *args, **kwargs):
|
||||||
|
with patch("platform.system", return_value=actual_system):
|
||||||
|
return release_gitter.match_asset(*args, **kwargs)
|
||||||
|
|
||||||
|
def new_expression(
|
||||||
|
actual_system: str,
|
||||||
|
system_mapping: dict[str, str] | None,
|
||||||
|
file_name: str,
|
||||||
|
expected: dict[str, str],
|
||||||
|
exception: type[Exception] | None = None,
|
||||||
|
msg: str | None = None,
|
||||||
):
|
):
|
||||||
with self.assertRaises(ValueError):
|
release = {
|
||||||
release_gitter.read_version()
|
"name": "v1.0.0",
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"assets": [{"name": file_name}],
|
||||||
|
}
|
||||||
|
return TestExpression(
|
||||||
|
self,
|
||||||
|
[actual_system, release, "file-{system}.zip"],
|
||||||
|
{"system_mapping": system_mapping},
|
||||||
|
expected,
|
||||||
|
exception,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_cases = chain(
|
||||||
|
[
|
||||||
|
new_expression(
|
||||||
|
"Earth",
|
||||||
|
None,
|
||||||
|
"file-Earth.zip",
|
||||||
|
{"name": "file-Earth.zip"},
|
||||||
|
msg="Current system always included as an exact match synonym",
|
||||||
|
),
|
||||||
|
new_expression(
|
||||||
|
"Linux",
|
||||||
|
{"Linux": "jumanji"},
|
||||||
|
"file-jumanji.zip",
|
||||||
|
{"name": "file-jumanji.zip"},
|
||||||
|
msg="Non-standard system mapping works",
|
||||||
|
),
|
||||||
|
new_expression(
|
||||||
|
"Linux",
|
||||||
|
{},
|
||||||
|
"file-darwin.zip",
|
||||||
|
{},
|
||||||
|
ValueError,
|
||||||
|
msg="No matching system",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
# Test default mappings
|
||||||
|
(
|
||||||
|
new_expression(
|
||||||
|
actual_system,
|
||||||
|
None,
|
||||||
|
file_name,
|
||||||
|
{"name": file_name},
|
||||||
|
msg="Default Linux mappings",
|
||||||
|
)
|
||||||
|
for actual_system, file_name in product(
|
||||||
|
("Linux", "linux"),
|
||||||
|
("file-Linux.zip", "file-linux.zip"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
new_expression(
|
||||||
|
actual_system,
|
||||||
|
None,
|
||||||
|
file_name,
|
||||||
|
{"name": file_name},
|
||||||
|
msg="Default macOS mappings",
|
||||||
|
)
|
||||||
|
for actual_system, file_name in product(
|
||||||
|
("Darwin", "darwin", "MacOS", "macos", "macOS"),
|
||||||
|
(
|
||||||
|
"file-Darwin.zip",
|
||||||
|
"file-darwin.zip",
|
||||||
|
"file-MacOS.zip",
|
||||||
|
"file-macos.zip",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
new_expression(
|
||||||
|
actual_system,
|
||||||
|
None,
|
||||||
|
file_name,
|
||||||
|
{"name": file_name},
|
||||||
|
msg="Default Windows mappings",
|
||||||
|
)
|
||||||
|
for actual_system, file_name in product(
|
||||||
|
("Windows", "windows", "win", "win32", "win64"),
|
||||||
|
(
|
||||||
|
"file-Windows.zip",
|
||||||
|
"file-windows.zip",
|
||||||
|
"file-win.zip",
|
||||||
|
"file-win32.zip",
|
||||||
|
"file-win64.zip",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for test_case in test_cases:
|
||||||
|
test_case.run(run_with_context)
|
||||||
|
|
||||||
|
def test_match_asset_archs(self, *_):
|
||||||
|
# Input variations:
|
||||||
|
# Case 1: Arch mapping provided
|
||||||
|
# Case 2: No arch mapping provided
|
||||||
|
|
||||||
|
# Test: We want to show that default matching will work out of the box with some values for the current machine
|
||||||
|
# Test: We want to show that non-standard mappings will always work if provided manually
|
||||||
|
|
||||||
|
def run_with_context(actual_arch: str, *args, **kwargs):
|
||||||
|
with patch("platform.machine", return_value=actual_arch):
|
||||||
|
return release_gitter.match_asset(*args, **kwargs)
|
||||||
|
|
||||||
|
def new_expression(
|
||||||
|
actual_arch: str,
|
||||||
|
arch_mapping: dict[str, str] | None,
|
||||||
|
file_name: str,
|
||||||
|
expected: dict[str, str],
|
||||||
|
exception: type[Exception] | None = None,
|
||||||
|
msg: str | None = None,
|
||||||
|
):
|
||||||
|
release = {
|
||||||
|
"name": "v1.0.0",
|
||||||
|
"tag_name": "v1.0.0",
|
||||||
|
"assets": [{"name": file_name}],
|
||||||
|
}
|
||||||
|
return TestExpression(
|
||||||
|
self,
|
||||||
|
[actual_arch, release, "file-{arch}.zip"],
|
||||||
|
{"arch_mapping": arch_mapping},
|
||||||
|
expected,
|
||||||
|
exception,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
test_cases = chain(
|
||||||
|
[
|
||||||
|
new_expression(
|
||||||
|
"Earth",
|
||||||
|
None,
|
||||||
|
"file-Earth.zip",
|
||||||
|
{"name": "file-Earth.zip"},
|
||||||
|
msg="Current arch always included as an exact match synonym",
|
||||||
|
),
|
||||||
|
new_expression(
|
||||||
|
"x86_64",
|
||||||
|
{"x86_64": "jumanji"},
|
||||||
|
"file-jumanji.zip",
|
||||||
|
{"name": "file-jumanji.zip"},
|
||||||
|
msg="Non-standard arch mapping works",
|
||||||
|
),
|
||||||
|
new_expression(
|
||||||
|
"x86_64",
|
||||||
|
{},
|
||||||
|
"file-arm.zip",
|
||||||
|
{},
|
||||||
|
ValueError,
|
||||||
|
msg="No matching arch",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
# Test default mappings
|
||||||
|
(
|
||||||
|
new_expression(
|
||||||
|
actual_arch,
|
||||||
|
None,
|
||||||
|
file_name,
|
||||||
|
{"name": file_name},
|
||||||
|
msg="Default arm mappings",
|
||||||
|
)
|
||||||
|
for actual_arch, file_name in product(
|
||||||
|
("arm",),
|
||||||
|
("file-arm.zip",),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
new_expression(
|
||||||
|
actual_arch,
|
||||||
|
None,
|
||||||
|
file_name,
|
||||||
|
{"name": file_name},
|
||||||
|
msg="Default amd64 mappings",
|
||||||
|
)
|
||||||
|
for actual_arch, file_name in product(
|
||||||
|
("amd64", "x86_64", "AMD64"),
|
||||||
|
("file-amd64.zip", "file-x86_64.zip"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
new_expression(
|
||||||
|
actual_arch,
|
||||||
|
None,
|
||||||
|
file_name,
|
||||||
|
{"name": file_name},
|
||||||
|
msg="Default arm64 mappings",
|
||||||
|
)
|
||||||
|
for actual_arch, file_name in product(
|
||||||
|
("arm64", "aarch64", "armv8b", "armv8l"),
|
||||||
|
(
|
||||||
|
"file-arm64.zip",
|
||||||
|
"file-aarch64.zip",
|
||||||
|
"file-armv8b.zip",
|
||||||
|
"file-armv8l.zip",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
new_expression(
|
||||||
|
actual_arch,
|
||||||
|
None,
|
||||||
|
file_name,
|
||||||
|
{"name": file_name},
|
||||||
|
msg="Default x86 mappings",
|
||||||
|
)
|
||||||
|
for actual_arch, file_name in product(
|
||||||
|
("x86", "i386", "i686"),
|
||||||
|
("file-x86.zip", "file-i386.zip", "file-i686.zip"),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for test_case in test_cases:
|
||||||
|
test_case.run(run_with_context)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
-e .
|
-e .
|
||||||
pytest
|
hatch
|
||||||
coverage
|
mypy
|
||||||
pre-commit
|
pre-commit
|
||||||
|
types-requests
|
||||||
|
types-toml
|
||||||
|
|
|
@ -11,6 +11,7 @@ version = "0.11.3"
|
||||||
extract-files = [ "stylua" ]
|
extract-files = [ "stylua" ]
|
||||||
format = "stylua-{version}-{system}.zip"
|
format = "stylua-{version}-{system}.zip"
|
||||||
exec = "chmod +x stylua"
|
exec = "chmod +x stylua"
|
||||||
|
|
||||||
[tool.release-gitter.map-system]
|
[tool.release-gitter.map-system]
|
||||||
Darwin = "macos"
|
Darwin = "macos"
|
||||||
Windows = "win64"
|
Windows = "win64"
|
||||||
|
|
40
setup.py
40
setup.py
|
@ -1,40 +0,0 @@
|
||||||
from codecs import open
|
|
||||||
from os import path
|
|
||||||
|
|
||||||
from setuptools import find_packages
|
|
||||||
from setuptools import setup
|
|
||||||
|
|
||||||
here = path.abspath(path.dirname(__file__))
|
|
||||||
|
|
||||||
# Get the long description from the README file
|
|
||||||
with open(path.join(here, "README.md"), encoding="utf-8") as f:
|
|
||||||
long_description = f.read()
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="release-gitter",
|
|
||||||
version="1.1.0",
|
|
||||||
description="Easily download releases from sites like Github and Gitea",
|
|
||||||
long_description=long_description,
|
|
||||||
long_description_content_type="text/markdown",
|
|
||||||
url="https://git.iamthefij.com/iamthefij/release-gitter.git",
|
|
||||||
download_url=(
|
|
||||||
"https://git.iamthefij.com/iamthefij/release-gitter.git/archive/master.tar.gz"
|
|
||||||
),
|
|
||||||
author="iamthefij",
|
|
||||||
author_email="",
|
|
||||||
classifiers=[
|
|
||||||
"Programming Language :: Python :: 3",
|
|
||||||
"Programming Language :: Python :: 3.7",
|
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
],
|
|
||||||
keywords="",
|
|
||||||
py_modules=["release_gitter", "pseudo_builder"],
|
|
||||||
install_requires=["requests"],
|
|
||||||
extras_require={"builder": ["toml", "wheel"]},
|
|
||||||
entry_points={
|
|
||||||
"console_scripts": [
|
|
||||||
"release-gitter=release_gitter:main",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
17
tox.ini
17
tox.ini
|
@ -1,17 +0,0 @@
|
||||||
[tox]
|
|
||||||
envlist = py3
|
|
||||||
|
|
||||||
[testenv]
|
|
||||||
deps =
|
|
||||||
-rrequirements-dev.txt
|
|
||||||
commands =
|
|
||||||
coverage erase
|
|
||||||
coverage run --source=release_gitter -m unittest discover . {posargs:"*_test.py"}
|
|
||||||
coverage report -m # --fail-under 70
|
|
||||||
pre-commit run --all-files
|
|
||||||
|
|
||||||
[testenv:pre-commit]
|
|
||||||
deps =
|
|
||||||
pre-commit
|
|
||||||
commands =
|
|
||||||
pre-commit {posargs}
|
|
Loading…
Reference in New Issue
Block a user