Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0c14d52
py(deps[test]): Add pytest-asyncio
tony Jun 19, 2024
646a42f
notes(docs[plan]): Add asyncio implementation plan
tony Dec 29, 2025
2cea07d
AGENTS(docs[asyncio]): Add asyncio development guidelines
tony Dec 29, 2025
df022bc
_internal(feat[async_subprocess]): Add async subprocess wrapper
tony Dec 29, 2025
7ade5c3
notes(docs[plan]): Add verification command requirement
tony Dec 29, 2025
3fea3d8
_internal(fix[async_subprocess]): Fix mypy type errors and formatting
tony Dec 29, 2025
7cdb017
_internal(feat[async_run]): Add async subprocess execution with callb…
tony Dec 29, 2025
55b23c5
cmd/_async(feat[AsyncGit]): Add async git command class
tony Dec 29, 2025
391a418
sync/_async(feat[AsyncGitSync]): Add async repository synchronization
tony Dec 29, 2025
0e50a71
pytest_plugin(feat): Add async_git_repo fixture
tony Dec 29, 2025
7fe3aa6
tests(async_git): Add tests for async_git_repo fixture
tony Dec 29, 2025
3fe8f81
py(pytest): Filter pytest-asyncio deprecation warning
tony Dec 29, 2025
8fdde6d
notes(plan): Mark Phase 5 complete
tony Dec 29, 2025
c8d4f0f
cmd/_async(feat): Add AsyncHg command class
tony Dec 29, 2025
2d935b0
sync/_async(feat): Add AsyncHgSync sync class
tony Dec 29, 2025
ac6ffb5
_async(chore): Export AsyncHg and AsyncHgSync
tony Dec 29, 2025
1d87670
tests(async_hg): Add AsyncHg command tests
tony Dec 29, 2025
316e3c1
tests(async_hg): Add AsyncHgSync sync tests
tony Dec 29, 2025
85ee7d2
notes(plan): Mark Phase 6 complete
tony Dec 29, 2025
8c6eb7a
cmd/_async(feat[AsyncSvn]): Add async Subversion command class
tony Dec 29, 2025
1c77d8a
sync/_async(feat[AsyncSvnSync]): Add async SVN sync class
tony Dec 29, 2025
330e62b
_async(chore): Export AsyncSvn and AsyncSvnSync
tony Dec 29, 2025
5060613
tests(async_svn): Add AsyncSvn command tests
tony Dec 29, 2025
9bb2e2e
tests(async_svn): Add AsyncSvnSync sync tests
tony Dec 29, 2025
da77a13
notes(plan): Mark Phase 7 complete
tony Dec 29, 2025
6be6459
pytest_plugin(feat): Add asyncio to doctest_namespace
tony Dec 29, 2025
7d1e198
_internal(fix): Enable async doctests to run
tony Dec 29, 2025
12143ad
docs(AGENTS): Add doctest guidelines and requirements
tony Dec 29, 2025
6a8f22a
docs(AGENTS): Clarify +SKIP is not permitted
tony Dec 29, 2025
e9d85a5
cmd,sync/_async(fix): Enable async doctests to run
tony Dec 29, 2025
50066b2
docs(topics): Add asyncio guide
tony Dec 29, 2025
8b9227e
docs(README): Add async section
tony Dec 29, 2025
a4a1087
docs(quickstart,pytest-plugin): Add async examples
tony Dec 29, 2025
8a84ed2
docs(internals): Add async_run and async_subprocess docs
tony Dec 29, 2025
28dbb29
docs(cmd,sync): Add async variant references
tony Dec 29, 2025
7249c13
notes(plan): Mark Phase 8 complete
tony Dec 29, 2025
22927f0
chore(tmuxp): Update workspace config to use just instead of make
tony Dec 29, 2025
6e7cdf1
pytest_plugin(feat): Add caching to async fixtures
tony Dec 29, 2025
ad76e1d
pytest_plugin(fix): Wrap long docstring line to satisfy ruff E501
tony Dec 29, 2025
a8afe6e
pytest_plugin(feat): Add fixture performance optimization and profiling
tony Dec 30, 2025
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
2 changes: 1 addition & 1 deletion .tmuxp.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ windows:
- focus: true
- pane
- pane
- make start
- just start
171 changes: 171 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,44 @@ type
"""
```

### Doctests

**All functions and methods MUST have working doctests.** Doctests serve as both documentation and tests.

**CRITICAL RULES:**
- Doctests MUST actually execute - never comment out `asyncio.run()` or similar calls
- Doctests MUST NOT be converted to `.. code-block::` as a workaround (code-blocks don't run)
- If you cannot create a working doctest, **STOP and ask for help**

**Available tools for doctests:**
- `doctest_namespace` fixtures: `tmp_path`, `asyncio`, `create_git_remote_repo`, `create_hg_remote_repo`, `create_svn_remote_repo`, `example_git_repo`
- Ellipsis for variable output: `# doctest: +ELLIPSIS`
- Update `pytest_plugin.py` to add new fixtures to `doctest_namespace`

**`# doctest: +SKIP` is NOT permitted** - it's just another workaround that doesn't test anything. If a VCS binary might not be installed, pytest already handles skipping via `skip_if_binaries_missing`. Use the fixtures properly.

**Async doctest pattern:**
```python
>>> async def example():
... result = await some_async_function()
... return result
>>> asyncio.run(example())
'expected output'
```

**Using fixtures in doctests:**
```python
>>> git = Git(path=tmp_path) # tmp_path from doctest_namespace
>>> git.run(['status'])
'...'
```

**When output varies, use ellipsis:**
```python
>>> git.clone(url=f'file://{create_git_remote_repo()}') # doctest: +ELLIPSIS
'Cloning into ...'
```

### Git Commit Standards

Format commit messages as:
Expand Down Expand Up @@ -257,6 +295,139 @@ EOF
)"
```

## Asyncio Development

### Architecture

libvcs async support is organized in `_async/` subpackages:

```
libvcs/
├── _internal/
│ ├── subprocess.py # Sync subprocess wrapper
│ └── async_subprocess.py # Async subprocess wrapper
├── cmd/
│ ├── git.py # Git (sync)
│ └── _async/git.py # AsyncGit
├── sync/
│ ├── git.py # GitSync (sync)
│ └── _async/git.py # AsyncGitSync
```

### Async Subprocess Patterns

**Always use `communicate()` for subprocess I/O:**
```python
proc = await asyncio.create_subprocess_shell(...)
stdout, stderr = await proc.communicate() # Prevents deadlocks
```

**Use `asyncio.timeout()` for timeouts:**
```python
async with asyncio.timeout(300):
stdout, stderr = await proc.communicate()
```

**Handle BrokenPipeError gracefully:**
```python
try:
proc.stdin.write(data)
await proc.stdin.drain()
except BrokenPipeError:
pass # Process already exited - expected behavior
```

### Async API Conventions

- **Class naming**: Use `Async` prefix: `AsyncGit`, `AsyncGitSync`
- **Callbacks**: Async APIs accept only async callbacks (no union types)
- **Shared logic**: Extract argument-building to sync functions, share with async

```python
# Shared argument building (sync)
def build_clone_args(url: str, depth: int | None = None) -> list[str]:
args = ["clone", url]
if depth:
args.extend(["--depth", str(depth)])
return args

# Async method uses shared logic
async def clone(self, url: str, depth: int | None = None) -> str:
args = build_clone_args(url, depth)
return await self.run(args)
```

### Async Testing

**pytest configuration:**
```toml
[tool.pytest.ini_options]
asyncio_mode = "strict"
asyncio_default_fixture_loop_scope = "function"
```

**Async fixture pattern:**
```python
@pytest_asyncio.fixture(loop_scope="function")
async def async_git_repo(tmp_path: Path) -> t.AsyncGenerator[AsyncGitSync, None]:
repo = AsyncGitSync(url="...", path=tmp_path / "repo")
await repo.obtain()
yield repo
```

**Parametrized async tests:**
```python
class CloneFixture(t.NamedTuple):
test_id: str
clone_kwargs: dict[str, t.Any]
expected: list[str]

CLONE_FIXTURES = [
CloneFixture("basic", {}, [".git"]),
CloneFixture("shallow", {"depth": 1}, [".git"]),
]

@pytest.mark.parametrize(
list(CloneFixture._fields),
CLONE_FIXTURES,
ids=[f.test_id for f in CLONE_FIXTURES],
)
@pytest.mark.asyncio
async def test_clone(test_id: str, clone_kwargs: dict, expected: list) -> None:
...
```

### Async Anti-Patterns

**DON'T poll returncode:**
```python
# WRONG
while proc.returncode is None:
await asyncio.sleep(0.1)

# RIGHT
await proc.wait()
```

**DON'T mix blocking calls in async code:**
```python
# WRONG
async def bad():
subprocess.run(["git", "clone", url]) # Blocks event loop!

# RIGHT
async def good():
proc = await asyncio.create_subprocess_shell(...)
await proc.wait()
```

**DON'T close the event loop in tests:**
```python
# WRONG - breaks pytest-asyncio cleanup
loop = asyncio.get_running_loop()
loop.close()
```

## Debugging Tips

When stuck in debugging loops:
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,46 @@ def test_my_git_tool(
assert (checkout_path / ".git").is_dir()
```

### 5. Async Support
Run VCS operations asynchronously for better concurrency when managing multiple repositories.

[**Learn more about Async Support**](https://libvcs.git-pull.com/topics/asyncio.html)

```python
import asyncio
import pathlib
from libvcs.sync._async.git import AsyncGitSync

async def main():
repo = AsyncGitSync(
url="https://github.com/vcs-python/libvcs",
path=pathlib.Path.cwd() / "libvcs",
)
await repo.obtain()
await repo.update_repo()

asyncio.run(main())
```

Clone multiple repositories concurrently:

```python
import asyncio
from libvcs.sync._async.git import AsyncGitSync

async def clone_all(repos: list[tuple[str, str]]):
tasks = [
AsyncGitSync(url=url, path=path).obtain()
for url, path in repos
]
await asyncio.gather(*tasks) # All clone in parallel

asyncio.run(clone_all([
("https://github.com/vcs-python/libvcs", "./libvcs"),
("https://github.com/vcs-python/vcspull", "./vcspull"),
]))
```

## Project Information

- **Python Support**: 3.10+
Expand Down
115 changes: 115 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,130 @@

from __future__ import annotations

import time
import typing as t
from collections import defaultdict

import pytest

if t.TYPE_CHECKING:
import pathlib

from _pytest.fixtures import FixtureDef, SubRequest
from _pytest.terminal import TerminalReporter

pytest_plugins = ["pytester"]

# Fixture profiling storage
_fixture_timings: dict[str, list[float]] = defaultdict(list)
_fixture_call_counts: dict[str, int] = defaultdict(int)


def pytest_addoption(parser: pytest.Parser) -> None:
"""Add fixture profiling options."""
group = parser.getgroup("libvcs", "libvcs fixture options")
group.addoption(
"--fixture-durations",
action="store",
type=int,
default=0,
metavar="N",
help="Show N slowest fixture setup times (N=0 for all)",
)
group.addoption(
"--fixture-durations-min",
action="store",
type=float,
default=0.005,
metavar="SECONDS",
help="Minimum duration to show in fixture timing report (default: 0.005)",
)
group.addoption(
"--run-performance",
action="store_true",
default=False,
help="Run performance tests (marked with @pytest.mark.performance)",
)


def pytest_collection_modifyitems(
config: pytest.Config,
items: list[pytest.Item],
) -> None:
"""Skip performance tests unless --run-performance is given."""
if config.getoption("--run-performance"):
# --run-performance given: run all tests
return

skip_performance = pytest.mark.skip(reason="need --run-performance option to run")
for item in items:
if "performance" in item.keywords:
item.add_marker(skip_performance)


@pytest.hookimpl(wrapper=True)
def pytest_fixture_setup(
fixturedef: FixtureDef[t.Any],
request: SubRequest,
) -> t.Generator[None, t.Any, t.Any]:
"""Wrap fixture setup to measure timing."""
start = time.perf_counter()
try:
result = yield
return result
finally:
duration = time.perf_counter() - start
fixture_name = fixturedef.argname
_fixture_timings[fixture_name].append(duration)
_fixture_call_counts[fixture_name] += 1


def pytest_terminal_summary(
terminalreporter: TerminalReporter,
exitstatus: int,
config: pytest.Config,
) -> None:
"""Display fixture timing summary."""
durations_count = config.option.fixture_durations
durations_min = config.option.fixture_durations_min

# Skip if no timing requested (durations_count defaults to 0 meaning "off")
if durations_count == 0 and not config.option.verbose:
return

# Build summary data
fixture_stats: list[tuple[str, float, int, float]] = []
for name, times in _fixture_timings.items():
total_time = sum(times)
call_count = len(times)
avg_time = total_time / call_count if call_count > 0 else 0
fixture_stats.append((name, total_time, call_count, avg_time))

# Sort by total time descending
fixture_stats.sort(key=lambda x: x[1], reverse=True)

# Filter by minimum duration
fixture_stats = [s for s in fixture_stats if s[1] >= durations_min]

if not fixture_stats:
return

# Limit count if specified
if durations_count > 0:
fixture_stats = fixture_stats[:durations_count]

terminalreporter.write_sep("=", "fixture setup times")
terminalreporter.write_line("")
terminalreporter.write_line(
f"{'Fixture':<40} {'Total':>10} {'Calls':>8} {'Avg':>10}",
)
terminalreporter.write_line("-" * 70)

for name, total, calls, avg in fixture_stats:
terminalreporter.write_line(
f"{name:<40} {total:>9.3f}s {calls:>8} {avg:>9.3f}s",
)


@pytest.fixture(autouse=True)
def add_doctest_fixtures(
Expand Down
10 changes: 10 additions & 0 deletions docs/cmd/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ The `libvcs.cmd` module provides Python wrappers for VCS command-line tools:
- {mod}`libvcs.cmd.hg` - Mercurial commands
- {mod}`libvcs.cmd.svn` - Subversion commands

### Async Variants

Async equivalents are available in `libvcs.cmd._async`:

- {class}`~libvcs.cmd._async.git.AsyncGit` - Async git commands
- {class}`~libvcs.cmd._async.hg.AsyncHg` - Async mercurial commands
- {class}`~libvcs.cmd._async.svn.AsyncSvn` - Async subversion commands

See {doc}`/topics/asyncio` for usage patterns.

### When to use `cmd` vs `sync`

| Module | Use Case |
Expand Down
8 changes: 8 additions & 0 deletions docs/internals/async_run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# async_run - `libvcs._internal.async_run`

Async equivalent of {mod}`libvcs._internal.run`.

```{eval-rst}
.. automodule:: libvcs._internal.async_run
:members:
```
Loading