Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions .github/workflows/package.yml → .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Build
name: CI

on:
push:
Expand All @@ -10,7 +10,38 @@ permissions:
contents: write

jobs:
check:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
architecture: x64

- name: Install dependencies
run: uv sync

- name: Ruff check
run: uv run ruff check src/ tests/

- name: Ruff format
run: uv run ruff format --check src/ tests/

- name: Mypy
run: uv run mypy src/

- name: Tests with coverage
run: uv run pytest --cov=src --cov-report=term-missing --cov-fail-under=100

build:
needs: check
runs-on: windows-latest
steps:
- name: Checkout
Expand All @@ -22,7 +53,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.12'
architecture: x64

- name: Install dependencies
Expand All @@ -33,7 +64,7 @@ jobs:
with:
path: |
~\AppData\Local\Nuitka
key: nuitka
key: nuitka-py3.12-${{ hashFiles('uv.lock') }}

- name: Build
run: |
Expand Down
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
/.idea
/build
/dist

# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/

# Coverage
.coverage
htmlcov/
coverage.xml

# pytest
.pytest_cache/
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.10.6
3.12
43 changes: 35 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,47 @@ authors = [
{name = "Roman Kharitonov", email = "rkharito@yandex.ru"},
]
license = {text = "GPL-3.0-or-later"}
requires-python = "<3.12,>=3.10"
requires-python = ">=3.12,<3.13"
dependencies = [
"tabulate<1.0.0,>=0.9.0",
"colorama<1.0.0,>=0.4.5",
"dataclasses-json<1.0.0,>=0.6.3",
"humanize<5.0.0,>=4.9.0",
"pygit2<2.0.0,>=1.14.0",
"colorama>=0.4.6",
"dataclasses-json>=0.6.7",
"humanize>=4.15.0",
"pygit2>=1.19.1",
"tabulate>=0.9.0",
]
name = "snapjaw"
version = "1.0"
description = "Vanilla WoW AddOn Manager"

[tool.ruff]
target-version = "py312"
line-length = 120

[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"]

[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
markers = [
"integration: mark test as integration test (requires network)",
"slow: mark test as slow",
]
addopts = "-m 'not integration'"

[dependency-groups]
dev = [
"nuitka<2.0.0,>=1.5.7",
"setuptools<70.0.0,>=69.0.3",
"mypy>=1.19.1",
"nuitka>=2.8.10",
"pytest>=9.0.2",
"pytest-cov>=7.0.0",
"ruff>=0.14.14",
"setuptools>=80.10.2",
"types-colorama>=0.4.15.20250801",
"types-tabulate>=0.9.0.20241207",
]
66 changes: 35 additions & 31 deletions src/mygit.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import math
from collections.abc import Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime
from hashlib import sha1
from multiprocessing import Process, Pipe
from multiprocessing import Pipe, Process
from multiprocessing.connection import Connection
from tempfile import TemporaryDirectory
from collections.abc import Iterator
from typing import Optional

import humanize
import pygit2
Expand All @@ -26,8 +25,8 @@ class RepositoryInfo:


# Workaround for https://github.com/libgit2/pygit2/issues/264
def clone(url: str, branch: Optional[str], path: str) -> RepositoryInfo:
def make_pipe() -> tuple[Connection, Connection]:
def clone(url: str, branch: str | None, path: str) -> RepositoryInfo:
def make_pipe():
return Pipe()

parent_data_conn, child_data_conn = make_pipe()
Expand All @@ -40,25 +39,26 @@ def make_pipe() -> tuple[Connection, Connection]:
if parent_error_conn.poll(0):
raise parent_error_conn.recv()

return parent_data_conn.recv()
result: RepositoryInfo = parent_data_conn.recv()
return result


def _clone(url: str, branch: Optional[str], path: str, data_conn: Connection, error_conn: Connection):
def _clone(url: str, branch: str | None, path: str, data_conn: Connection, error_conn: Connection):
try:
repo: pygit2.Repository = pygit2.clone_repository(url,
path,
depth=1,
checkout_branch=branch,
callbacks=_GitProgressCallbacks())
repo: pygit2.Repository = pygit2.clone_repository(
url, path, depth=1, checkout_branch=branch, callbacks=_GitProgressCallbacks()
)
except (pygit2.GitError, KeyError) as error:
error_conn.send(GitError(str(error)))
return
head: pygit2.Commit = repo[repo.head.target]
head = repo[repo.head.target]
assert isinstance(head, pygit2.Commit)
info = RepositoryInfo(
workdir=repo.workdir,
branch=repo.head.shorthand,
head_commit_hex=str(head.id),
head_commit_time=datetime.fromtimestamp(head.commit_time))
head_commit_time=datetime.fromtimestamp(head.commit_time),
)
data_conn.send(info)


Expand All @@ -70,27 +70,27 @@ def __init__(self):
self._max_progress_len = 0

def sideband_progress(self, progress: str) -> None:
print(progress, end='\r')
print(progress, end="\r")

def transfer_progress(self, progress: pygit2.remotes.TransferProgress) -> None:
def print_progress(prefix: str, suffix: str, cur_count: int, max_count: int) -> None:
eol = '\r'
text = f'{prefix}: {math.ceil(cur_count / max_count * 100)}% ({cur_count}/{max_count}) {suffix}'
eol = "\r"
text = f"{prefix}: {math.ceil(cur_count / max_count * 100)}% ({cur_count}/{max_count}) {suffix}"
if cur_count == max_count:
eol = '\n'
text = f'{text.strip()}, done.'
eol = "\n"
text = f"{text.strip()}, done."
self._max_progress_len = max(self._max_progress_len, len(text))
print(text.ljust(self._max_progress_len), end=eol)

if not self._objects_done:
a, b = progress.received_objects, progress.total_objects
size = humanize.naturalsize(progress.received_bytes)
print_progress('Receiving objects', f'[{size}]', a, b)
print_progress("Receiving objects", f"[{size}]", a, b)
self._objects_done = a >= b
elif not self._deltas_done:
a, b = progress.indexed_deltas, progress.total_deltas
if b > 0:
print_progress('Indexing deltas', '', a, b)
print_progress("Indexing deltas", "", a, b)
self._deltas_done = a >= b


Expand All @@ -104,23 +104,23 @@ class RemoteStateRequest:
class RemoteState:
url: str
branch: str
head_commit_hex: Optional[str]
error: Optional[str]
head_commit_hex: str | None
error: str | None


@dataclass
class _RemoteLsResult:
remote: pygit2.Remote
refs: list[dict]
error: Optional[str]
error: str | None


def fetch_states(requests: list[RemoteStateRequest]) -> Iterator[RemoteState]:
with TemporaryDirectory() as repo_dir:
repo = pygit2.init_repository(repo_dir)
remote_name_to_branches = {}
remote_name_to_branches: dict[str, list[str]] = {}
for request in requests:
name = sha1(request.url.encode('utf-8')).hexdigest()
name = sha1(request.url.encode("utf-8")).hexdigest()
if not _has_remote(repo, name):
repo.remotes.create(name, request.url)
remote_name_to_branches.setdefault(name, []).append(request.branch)
Expand All @@ -139,14 +139,18 @@ def ls(remote: pygit2.Remote) -> _RemoteLsResult:
for future in as_completed(futures):
ls_result: _RemoteLsResult = future.result()

for branch in remote_name_to_branches[ls_result.remote.name]:
remote_name = ls_result.remote.name
assert remote_name is not None
for branch in remote_name_to_branches[remote_name]:
url = ls_result.remote.url or ""
if ls_result.error is not None:
yield RemoteState(ls_result.remote.url, branch, None, ls_result.error)
yield RemoteState(url, branch, None, ls_result.error)
else:
branch_ref = f'refs/heads/{branch}'
branch_ref = f"refs/heads/{branch}"
for ref in ls_result.refs:
if ref['name'] == 'HEAD' and ref['symref_target'] == branch_ref or ref['name'] == branch_ref:
yield RemoteState(ls_result.remote.url, branch, str(ref['oid']), None)
is_head = ref["name"] == "HEAD" and ref["symref_target"] == branch_ref
if is_head or ref["name"] == branch_ref:
yield RemoteState(url, branch, str(ref["oid"]), None)
break


Expand Down
17 changes: 9 additions & 8 deletions src/signature.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import hashlib
import os
from typing import Generator
from collections.abc import Generator, Iterable

_LATEST_VERSION = 2

Expand All @@ -15,25 +15,26 @@ def validate(dirpath: str, signature: str) -> bool:


def _pack(checksum: str, version) -> str:
return f'{checksum}|{version}'
return f"{checksum}|{version}"


def _unpack(signature: str) -> tuple[str, int]:
if '|' not in signature:
if "|" not in signature:
return signature, 1
checksum, version = signature.split('|')
checksum, version = signature.split("|")
return checksum, int(version)


def _get_checksum(dirpath: str, version: int) -> str:
if not os.path.isdir(dirpath):
raise ValueError(f'Directory "{dirpath}" not found')
chunks: Iterable[str]
if version == 1:
chunks = sorted(_get_dir_chunks_v1(dirpath))
elif version == 2:
chunks = _get_dir_chunks_v2(dirpath)
else:
raise RuntimeError('Invalid hash version')
raise RuntimeError("Invalid hash version")
return _hash(chunks)


Expand All @@ -53,18 +54,18 @@ def _get_dir_chunks_v2(dirpath: str) -> Generator[str, None, None]:


def _get_file_chunks(filepath: str) -> Generator[bytes, None, None]:
with open(filepath, 'rb') as filehandle:
with open(filepath, "rb") as filehandle:
while True:
data = filehandle.read(64 * 1024)
if not data:
break
yield data


def _hash(chunks: Generator[bytes|str, None, None]) -> str:
def _hash(chunks: Iterable[bytes | str]) -> str:
hasher = hashlib.sha1()
for chunk in chunks:
if isinstance(chunk, str):
chunk = chunk.encode('utf-8')
chunk = chunk.encode("utf-8")
hasher.update(chunk)
return hasher.hexdigest()
Loading