Add python_git: a leak-safe GitPython alternative
Every git invocation runs through a single blocking subprocess.run call in python_git/runner.py that always waits for the process to exit and closes its pipes before returning, so there is no long-lived Popen handle for a caller to forget to close (the root cause of GitPython's known fd/process leaks). Repo exposes status/log/diff/add/commit/push/pull/fetch, branch and remote management, and clone, backed by dataclasses in models.py. Covered by tests/test_repo.py, including an fd-count regression test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
ebe7ec6f4b
15 files changed
+527
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
*.egg-info/
|
||||||
Generated
+5
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="~/PycharmProjects/python-git/.venv" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/python-git.iml" filepath="$PROJECT_DIR$/.idea/python-git.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module external.system.id="pyproject.toml" type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="~/PycharmProjects/python-git/.venv" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PackageRequirementsSettings" />
|
||||||
|
<component name="PyDocumentationSettings" />
|
||||||
|
<component name="ReSTService" />
|
||||||
|
<component name="TestRunnerService" />
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[project]
|
||||||
|
name = "python-git"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["pytest>=8.0"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["python_git"]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from .exceptions import GitCommandError, GitError, InvalidGitRepositoryError
|
||||||
|
from .models import Branch, Commit, FileStatus, Remote
|
||||||
|
from .repo import Repo
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Repo",
|
||||||
|
"GitError",
|
||||||
|
"GitCommandError",
|
||||||
|
"InvalidGitRepositoryError",
|
||||||
|
"FileStatus",
|
||||||
|
"Commit",
|
||||||
|
"Branch",
|
||||||
|
"Remote",
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class GitError(Exception):
|
||||||
|
"""Base exception for all errors raised by python_git."""
|
||||||
|
|
||||||
|
|
||||||
|
class GitCommandError(GitError):
|
||||||
|
"""A git subprocess exited with a non-zero status."""
|
||||||
|
|
||||||
|
def __init__(self, cmd: list[str], returncode: int, stderr: str):
|
||||||
|
self.cmd = cmd
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stderr = stderr
|
||||||
|
super().__init__(
|
||||||
|
f"'{' '.join(cmd)}' failed (exit {returncode}): {stderr.strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidGitRepositoryError(GitError):
|
||||||
|
"""The given path is not inside a git working tree."""
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileStatus:
|
||||||
|
path: str
|
||||||
|
index_status: str
|
||||||
|
worktree_status: str
|
||||||
|
orig_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Commit:
|
||||||
|
hash: str
|
||||||
|
author_name: str
|
||||||
|
author_email: str
|
||||||
|
date: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Branch:
|
||||||
|
name: str
|
||||||
|
is_current: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Remote:
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .exceptions import GitCommandError, InvalidGitRepositoryError
|
||||||
|
from .models import Branch, Commit, FileStatus, Remote
|
||||||
|
from .runner import DEFAULT_TIMEOUT, run_git
|
||||||
|
|
||||||
|
_LOG_FIELD_SEP = "\x1f"
|
||||||
|
_LOG_RECORD_SEP = "\x1e"
|
||||||
|
|
||||||
|
|
||||||
|
class Repo:
|
||||||
|
def __init__(self, path: str | Path, timeout: float = DEFAULT_TIMEOUT):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
if not self.path.exists():
|
||||||
|
raise InvalidGitRepositoryError(f"{self.path} does not exist")
|
||||||
|
|
||||||
|
result = run_git(
|
||||||
|
"rev-parse", "--is-inside-work-tree",
|
||||||
|
cwd=str(self.path), timeout=timeout, check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or result.stdout.strip() != "true":
|
||||||
|
raise InvalidGitRepositoryError(f"{self.path} is not a git repository")
|
||||||
|
|
||||||
|
def _run(self, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
return run_git(*args, cwd=str(self.path), timeout=self.timeout, check=check)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clone(cls, url: str, to_path: str | Path, timeout: float = DEFAULT_TIMEOUT) -> "Repo":
|
||||||
|
run_git("clone", url, str(to_path), timeout=timeout)
|
||||||
|
return cls(to_path, timeout=timeout)
|
||||||
|
|
||||||
|
def status(self) -> list[FileStatus]:
|
||||||
|
tokens = self._run("status", "--porcelain=v2", "-z").stdout.split("\0")
|
||||||
|
entries: list[FileStatus] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(tokens):
|
||||||
|
token = tokens[i]
|
||||||
|
if not token:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
kind = token[0]
|
||||||
|
if kind == "1":
|
||||||
|
parts = token.split(" ", 8)
|
||||||
|
entries.append(FileStatus(path=parts[8], index_status=parts[1][0], worktree_status=parts[1][1]))
|
||||||
|
i += 1
|
||||||
|
elif kind == "2":
|
||||||
|
parts = token.split(" ", 9)
|
||||||
|
entries.append(FileStatus(
|
||||||
|
path=parts[9], index_status=parts[1][0], worktree_status=parts[1][1],
|
||||||
|
orig_path=tokens[i + 1],
|
||||||
|
))
|
||||||
|
i += 2
|
||||||
|
elif kind == "u":
|
||||||
|
parts = token.split(" ", 10)
|
||||||
|
entries.append(FileStatus(path=parts[10], index_status=parts[1][0], worktree_status=parts[1][1]))
|
||||||
|
i += 1
|
||||||
|
elif kind in ("?", "!"):
|
||||||
|
entries.append(FileStatus(path=token[2:], index_status=kind, worktree_status=kind))
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def is_dirty(self) -> bool:
|
||||||
|
return bool(self.status())
|
||||||
|
|
||||||
|
def log(self, max_count: int | None = None, ref: str = "HEAD") -> list[Commit]:
|
||||||
|
args = ["log", "--date=iso", f"--pretty=format:%H{_LOG_FIELD_SEP}%an{_LOG_FIELD_SEP}%ae{_LOG_FIELD_SEP}%ad{_LOG_FIELD_SEP}%s{_LOG_RECORD_SEP}"]
|
||||||
|
if max_count is not None:
|
||||||
|
args.append(f"--max-count={max_count}")
|
||||||
|
args.append(ref)
|
||||||
|
|
||||||
|
result = self._run(*args, check=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
if "does not have any commits yet" in result.stderr or "unknown revision" in result.stderr:
|
||||||
|
return []
|
||||||
|
raise GitCommandError(args, result.returncode, result.stderr)
|
||||||
|
|
||||||
|
commits = []
|
||||||
|
for record in result.stdout.split(_LOG_RECORD_SEP):
|
||||||
|
record = record.strip("\n")
|
||||||
|
if not record:
|
||||||
|
continue
|
||||||
|
h, an, ae, ad, s = record.split(_LOG_FIELD_SEP)
|
||||||
|
commits.append(Commit(hash=h, author_name=an, author_email=ae, date=ad, message=s))
|
||||||
|
return commits
|
||||||
|
|
||||||
|
def diff(self, *paths: str, staged: bool = False) -> str:
|
||||||
|
args = ["diff", "--staged"] if staged else ["diff"]
|
||||||
|
if paths:
|
||||||
|
args.extend(["--", *paths])
|
||||||
|
return self._run(*args).stdout
|
||||||
|
|
||||||
|
def add(self, *paths: str) -> None:
|
||||||
|
if not paths:
|
||||||
|
raise ValueError("add() requires at least one path")
|
||||||
|
self._run("add", "--", *paths)
|
||||||
|
|
||||||
|
def commit(self, message: str, all: bool = False) -> str:
|
||||||
|
args = ["commit", "-a", "-m", message] if all else ["commit", "-m", message]
|
||||||
|
return self._run(*args).stdout
|
||||||
|
|
||||||
|
def push(self, remote: str = "origin", branch: str | None = None) -> str:
|
||||||
|
args = ["push", remote, branch] if branch else ["push", remote]
|
||||||
|
return self._run(*args).stdout
|
||||||
|
|
||||||
|
def pull(self, remote: str = "origin", branch: str | None = None) -> str:
|
||||||
|
args = ["pull", remote, branch] if branch else ["pull", remote]
|
||||||
|
return self._run(*args).stdout
|
||||||
|
|
||||||
|
def fetch(self, remote: str = "origin") -> str:
|
||||||
|
return self._run("fetch", remote).stdout
|
||||||
|
|
||||||
|
def current_branch(self) -> str:
|
||||||
|
return self._run("rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
def list_branches(self) -> list[Branch]:
|
||||||
|
result = self._run("branch", f"--format=%(HEAD){_LOG_FIELD_SEP}%(refname:short)")
|
||||||
|
branches = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
head, name = line.split(_LOG_FIELD_SEP)
|
||||||
|
branches.append(Branch(name=name, is_current=(head == "*")))
|
||||||
|
return branches
|
||||||
|
|
||||||
|
def create_branch(self, name: str) -> None:
|
||||||
|
self._run("branch", name)
|
||||||
|
|
||||||
|
def checkout(self, name: str) -> None:
|
||||||
|
self._run("checkout", name)
|
||||||
|
|
||||||
|
def list_remotes(self) -> list[Remote]:
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
for line in self._run("remote", "-v").stdout.splitlines():
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
name, rest = line.split("\t", 1)
|
||||||
|
url = rest.rsplit(" ", 1)[0]
|
||||||
|
seen[name] = url
|
||||||
|
return [Remote(name=name, url=url) for name, url in seen.items()]
|
||||||
|
|
||||||
|
def add_remote(self, name: str, url: str) -> None:
|
||||||
|
self._run("remote", "add", name, url)
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""The single chokepoint for running the ``git`` binary.
|
||||||
|
|
||||||
|
Every call goes through :func:`run_git`, which always uses the blocking
|
||||||
|
``subprocess.run`` (never a bare ``Popen`` kept alive across calls). The
|
||||||
|
process is started, waited on to completion, and its pipes are closed before
|
||||||
|
the function returns — there is no long-lived subprocess handle for a caller
|
||||||
|
to forget to close, which is the root cause of GitPython's process/fd leaks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from .exceptions import GitCommandError, GitError
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def git_executable() -> str:
|
||||||
|
path = shutil.which("git")
|
||||||
|
if path is None:
|
||||||
|
raise GitError("git executable not found. Please install git.")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(
|
||||||
|
*args: str,
|
||||||
|
cwd: str | None = None,
|
||||||
|
timeout: float = DEFAULT_TIMEOUT,
|
||||||
|
check: bool = True,
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
cmd = [git_executable(), *args]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
shell=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise GitCommandError(cmd, -1, f"timed out after {timeout}s: {exc}") from exc
|
||||||
|
|
||||||
|
if check and result.returncode != 0:
|
||||||
|
raise GitCommandError(cmd, result.returncode, result.stderr)
|
||||||
|
|
||||||
|
return result
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from python_git import GitCommandError, InvalidGitRepositoryError, Repo
|
||||||
|
|
||||||
|
|
||||||
|
def _init_repo(path: Path) -> Repo:
|
||||||
|
subprocess.run(["git", "init", "-q", str(path)], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(path), "config", "user.name", "Test User"], check=True)
|
||||||
|
return Repo(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_repository_raises(tmp_path):
|
||||||
|
with pytest.raises(InvalidGitRepositoryError):
|
||||||
|
Repo(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_path_raises(tmp_path):
|
||||||
|
with pytest.raises(InvalidGitRepositoryError):
|
||||||
|
Repo(tmp_path / "does-not-exist")
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_reports_untracked_and_spaced_filenames(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
(tmp_path / "a file.txt").write_text("hello\n")
|
||||||
|
|
||||||
|
statuses = repo.status()
|
||||||
|
|
||||||
|
assert len(statuses) == 1
|
||||||
|
assert statuses[0].path == "a file.txt"
|
||||||
|
assert statuses[0].index_status == "?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_commit_log_roundtrip(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
(tmp_path / "file.txt").write_text("hello\n")
|
||||||
|
repo.add("file.txt")
|
||||||
|
repo.commit("initial commit")
|
||||||
|
|
||||||
|
commits = repo.log()
|
||||||
|
|
||||||
|
assert len(commits) == 1
|
||||||
|
assert commits[0].message == "initial commit"
|
||||||
|
assert commits[0].author_email == "test@example.com"
|
||||||
|
assert not repo.is_dirty()
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_on_empty_repo_returns_empty_list(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
assert repo.log() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_shows_modification(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
(tmp_path / "file.txt").write_text("hello\n")
|
||||||
|
repo.add("file.txt")
|
||||||
|
repo.commit("initial")
|
||||||
|
(tmp_path / "file.txt").write_text("goodbye\n")
|
||||||
|
|
||||||
|
assert "goodbye" in repo.diff()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_is_tracked(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
content = "hello world this is long enough to be detected as a rename\n"
|
||||||
|
(tmp_path / "old.txt").write_text(content)
|
||||||
|
repo.add("old.txt")
|
||||||
|
repo.commit("add old")
|
||||||
|
(tmp_path / "old.txt").rename(tmp_path / "new.txt")
|
||||||
|
repo.add("new.txt", "old.txt")
|
||||||
|
|
||||||
|
renamed = [s for s in repo.status() if s.orig_path]
|
||||||
|
|
||||||
|
assert len(renamed) == 1
|
||||||
|
assert renamed[0].path == "new.txt"
|
||||||
|
assert renamed[0].orig_path == "old.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_create_and_checkout(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
(tmp_path / "file.txt").write_text("hello\n")
|
||||||
|
repo.add("file.txt")
|
||||||
|
repo.commit("initial")
|
||||||
|
|
||||||
|
repo.create_branch("feature")
|
||||||
|
repo.checkout("feature")
|
||||||
|
|
||||||
|
assert repo.current_branch() == "feature"
|
||||||
|
branches = {b.name: b.is_current for b in repo.list_branches()}
|
||||||
|
assert branches["feature"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_command_error_on_nonexistent_branch(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
with pytest.raises(GitCommandError):
|
||||||
|
repo.checkout("does-not-exist")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_fd_leak_across_many_calls(tmp_path):
|
||||||
|
repo = _init_repo(tmp_path)
|
||||||
|
(tmp_path / "file.txt").write_text("hello\n")
|
||||||
|
repo.add("file.txt")
|
||||||
|
repo.commit("initial")
|
||||||
|
|
||||||
|
fd_dir = Path("/proc/self/fd")
|
||||||
|
if not fd_dir.exists():
|
||||||
|
pytest.skip("requires /proc/self/fd (Linux)")
|
||||||
|
|
||||||
|
before = len(os.listdir(fd_dir))
|
||||||
|
for _ in range(200):
|
||||||
|
repo.status()
|
||||||
|
repo.log()
|
||||||
|
after = len(os.listdir(fd_dir))
|
||||||
|
|
||||||
|
assert after <= before
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-git"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [{ name = "pytest", specifier = ">=8.0" }]
|
||||||
Reference in new issue
Block a user