giterator 0.4.0__tar.gz → 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {giterator-0.4.0 → giterator-1.0.0}/AGENTS.md +9 -0
- {giterator-0.4.0 → giterator-1.0.0}/PKG-INFO +15 -2
- giterator-1.0.0/README.rst +25 -0
- giterator-1.0.0/conftest.py +34 -0
- giterator-1.0.0/docs/api.rst +28 -0
- giterator-1.0.0/docs/changes.rst +77 -0
- giterator-1.0.0/docs/use.rst +341 -0
- {giterator-0.4.0 → giterator-1.0.0}/pyproject.toml +3 -2
- giterator-1.0.0/src/giterator/__init__.py +15 -0
- giterator-1.0.0/src/giterator/cli.py +88 -0
- {giterator-0.4.0 → giterator-1.0.0}/src/giterator/git.py +86 -6
- giterator-1.0.0/src/giterator/iterate.py +194 -0
- giterator-1.0.0/src/giterator/testing.py +97 -0
- giterator-1.0.0/tests/test_cli.py +131 -0
- {giterator-0.4.0 → giterator-1.0.0}/tests/test_git.py +103 -3
- giterator-1.0.0/tests/test_iterate.py +300 -0
- {giterator-0.4.0 → giterator-1.0.0}/tests/test_testing.py +15 -8
- giterator-0.4.0/README.rst +0 -12
- giterator-0.4.0/docs/api.rst +0 -12
- giterator-0.4.0/docs/changes.rst +0 -39
- giterator-0.4.0/docs/use.rst +0 -13
- giterator-0.4.0/src/giterator/__init__.py +0 -7
- giterator-0.4.0/src/giterator/cli.py +0 -34
- giterator-0.4.0/src/giterator/testing.py +0 -77
- giterator-0.4.0/tests/test_cli.py +0 -45
- {giterator-0.4.0 → giterator-1.0.0}/.github/workflows/ci.yml +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/.github/workflows/release.yml +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/.gitignore +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/.python-version +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/.readthedocs.yml +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/CLAUDE.md +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/LICENSE.rst +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/docs/Makefile +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/docs/conf.py +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/docs/development.rst +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/docs/index.rst +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/docs/license.rst +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/happy.sh +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/src/giterator/__main__.py +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/src/giterator/clock.py +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/src/giterator/py.typed +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/src/giterator/typing.py +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/tests/__init__.py +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/tests/conftest.py +0 -0
- {giterator-0.4.0 → giterator-1.0.0}/tests/test_clock.py +0 -0
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
- **Done means green**: a change is only complete when `./happy.sh` exits 0; do not commit until it does.
|
|
6
6
|
- **No unrelated failures**: if `./happy.sh` fails on something unrelated to your changes, do NOT assume it is a pre-existing problem and proceed anyway. Stop immediately and ask the user how to proceed.
|
|
7
7
|
- **Docs for everything public**: new functionality or public API changes must have accompanying docs in `docs/*.rst`
|
|
8
|
+
- **Docs examples must run**: every Python code block and doctest in `docs/*.rst` is executed by pytest via sybil, top to bottom per document in one shared namespace. The root `conftest.py` sandboxes each document (fresh temp cwd, git config isolated), so examples must only touch disk via relative paths or `tmp_path`. Use `.. invisible-code-block: python` for setup that shouldn't render, doctests (`>>>`) to show output or tracebacks, and `run_pytest` from `sybil.testing` to actually execute fixture-style test examples.
|
|
8
9
|
- **No em-dashes or parenthetical asides in prose**: in `docs/*.rst` prose and Python docstrings, never use em-dashes, and never tuck a clause inside parentheses; rephrase with commas or separate sentences. This does not apply to code comments or agent-facing notes such as this file, where both are fine.
|
|
9
10
|
- **No stacked headings in docs**: a heading in `docs/*.rst` must be followed by prose, never immediately by a sub-heading. Add a short lead-in or merge the levels.
|
|
10
11
|
- **Type-annotate public APIs**: all public functions and classes need type annotations; mypy is the gate
|
|
11
12
|
- **Use `compare()` in tests**: assert with `compare(actual, expected=...)`, using `StringComparison` for pattern matches. Bare `assert` only for booleans and `isinstance` (which type-narrows for mypy).
|
|
13
|
+
- **No `pytest.MonkeyPatch`**: use testfixtures instead. `TempDirectory(cwd=True)` replaces `monkeypatch.chdir`; for mocking, prefer `Replacer` methods (`replace.in_environ`, `replace.on_class`, `replace.in_module`), or the `replace_in_environ`/`replace_on_class`/`replace_in_module` context managers for one-offs.
|
|
12
14
|
- **No `noqa`, ever**: this project has zero linter suppressions; don't add them. Fix the underlying issue instead.
|
|
13
15
|
- **No `docs/changes.rst` edits during development**: that file is updated at release time, not as part of feature work.
|
|
14
16
|
|
|
@@ -57,3 +59,10 @@ Testing specifics:
|
|
|
57
59
|
`COVERAGE_PROCESS_START` and the `coverage-enable-subprocess` dev dependency measures
|
|
58
60
|
them, so coverage runs in parallel mode.
|
|
59
61
|
- Tests exercise real git via temporary repositories, so a `git` binary is required.
|
|
62
|
+
- Docs examples are collected via the root `conftest.py` (sybil); `testpaths` includes
|
|
63
|
+
`docs`. Sandboxing is done with pytest fixtures requested via `Sybil(fixtures=...)`:
|
|
64
|
+
a module-scoped fixture spans exactly one document, and the `sandbox` fixture uses
|
|
65
|
+
that to chdir into a `TempDirectory(cwd=True)` and point
|
|
66
|
+
`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM` at controlled files, so examples can never
|
|
67
|
+
read or write the machine's git config or real filesystem paths. The builtin
|
|
68
|
+
`tmp_path` is also requested, fresh per example.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: giterator
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: Python tools for doing git things.
|
|
5
5
|
Project-URL: Homepage, https://github.com/simplistix/giterator
|
|
6
6
|
Project-URL: Documentation, https://giterator.readthedocs.org/en/latest/
|
|
@@ -26,4 +26,17 @@ giterator
|
|
|
26
26
|
.. |Docs| image:: https://readthedocs.org/projects/giterator/badge/?version=latest
|
|
27
27
|
.. _Docs: http://giterator.readthedocs.org/en/latest/
|
|
28
28
|
|
|
29
|
-
Python tools for doing git things
|
|
29
|
+
Python tools for doing git things:
|
|
30
|
+
|
|
31
|
+
- ``Git`` wraps command-line git for scripting everyday repository operations,
|
|
32
|
+
with structured access to the log.
|
|
33
|
+
|
|
34
|
+
- ``read`` and ``write`` replay a repository's history as a series of dated
|
|
35
|
+
snapshots and turn dated snapshots, such as backups, back into history.
|
|
36
|
+
The ``giterator`` command line tool packs and unpacks dated files in the
|
|
37
|
+
same way.
|
|
38
|
+
|
|
39
|
+
- ``giterator.testing.Repo`` makes sample repositories for automated tests,
|
|
40
|
+
with machine-independent configuration and single-call commits.
|
|
41
|
+
|
|
42
|
+
Full documentation is available at http://giterator.readthedocs.org/en/latest/
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
giterator
|
|
2
|
+
=========
|
|
3
|
+
|
|
4
|
+
|CI|_ |Docs|_
|
|
5
|
+
|
|
6
|
+
.. |CI| image:: https://github.com/simplistix/giterator/actions/workflows/ci.yml/badge.svg
|
|
7
|
+
.. _CI: https://github.com/simplistix/giterator/actions/workflows/ci.yml
|
|
8
|
+
|
|
9
|
+
.. |Docs| image:: https://readthedocs.org/projects/giterator/badge/?version=latest
|
|
10
|
+
.. _Docs: http://giterator.readthedocs.org/en/latest/
|
|
11
|
+
|
|
12
|
+
Python tools for doing git things:
|
|
13
|
+
|
|
14
|
+
- ``Git`` wraps command-line git for scripting everyday repository operations,
|
|
15
|
+
with structured access to the log.
|
|
16
|
+
|
|
17
|
+
- ``read`` and ``write`` replay a repository's history as a series of dated
|
|
18
|
+
snapshots and turn dated snapshots, such as backups, back into history.
|
|
19
|
+
The ``giterator`` command line tool packs and unpacks dated files in the
|
|
20
|
+
same way.
|
|
21
|
+
|
|
22
|
+
- ``giterator.testing.Repo`` makes sample repositories for automated tests,
|
|
23
|
+
with machine-independent configuration and single-call commits.
|
|
24
|
+
|
|
25
|
+
Full documentation is available at http://giterator.readthedocs.org/en/latest/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from doctest import ELLIPSIS, REPORT_NDIFF
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sybil import Sybil
|
|
7
|
+
from sybil.parsers.rest import DocTestParser, PythonCodeBlockParser, SkipParser
|
|
8
|
+
from testfixtures import Replacer, TempDirectory
|
|
9
|
+
|
|
10
|
+
GIT_CONFIG = (
|
|
11
|
+
b'[user]\n\tname = Giterator\n\temail = giterator@example.com\n[init]\n\tdefaultBranch = main\n'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture(scope='module')
|
|
16
|
+
def sandbox() -> Iterator[None]:
|
|
17
|
+
# Module scope makes this one sandbox per document: examples create real
|
|
18
|
+
# repos with relative paths, so each document runs in its own temporary
|
|
19
|
+
# directory, with git pointed away from the machine's own configuration.
|
|
20
|
+
with TempDirectory(cwd=True) as tempdir, Replacer() as replace:
|
|
21
|
+
replace.in_environ('GIT_CONFIG_GLOBAL', tempdir.write('gitconfig', GIT_CONFIG))
|
|
22
|
+
replace.in_environ('GIT_CONFIG_SYSTEM', os.devnull)
|
|
23
|
+
yield
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
pytest_collect_file = Sybil(
|
|
27
|
+
parsers=[
|
|
28
|
+
DocTestParser(optionflags=REPORT_NDIFF | ELLIPSIS),
|
|
29
|
+
PythonCodeBlockParser(),
|
|
30
|
+
SkipParser(),
|
|
31
|
+
],
|
|
32
|
+
pattern='*.rst',
|
|
33
|
+
fixtures=['sandbox', 'tmp_path'],
|
|
34
|
+
).pytest()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
API Reference
|
|
2
|
+
=============
|
|
3
|
+
|
|
4
|
+
Everything documented here is importable directly from ``giterator``, except
|
|
5
|
+
for :class:`~giterator.testing.Repo`, which lives in ``giterator.testing`` so
|
|
6
|
+
that test-only code is never pulled in by normal use.
|
|
7
|
+
|
|
8
|
+
giterator
|
|
9
|
+
~~~~~~~~~
|
|
10
|
+
|
|
11
|
+
.. automodule:: giterator
|
|
12
|
+
:members:
|
|
13
|
+
:special-members: __call__
|
|
14
|
+
:member-order: bysource
|
|
15
|
+
|
|
16
|
+
.. data:: daily
|
|
17
|
+
:type: Every
|
|
18
|
+
|
|
19
|
+
A daily schedule, for use with :func:`read`.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
giterator.testing
|
|
23
|
+
~~~~~~~~~~~~~~~~~~
|
|
24
|
+
|
|
25
|
+
.. automodule:: giterator.testing
|
|
26
|
+
:members:
|
|
27
|
+
:member-order: bysource
|
|
28
|
+
:show-inheritance:
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
.. py:currentmodule:: giterator
|
|
2
|
+
|
|
3
|
+
Changes
|
|
4
|
+
=======
|
|
5
|
+
|
|
6
|
+
1.0.0 (6 Jul 2026)
|
|
7
|
+
------------------
|
|
8
|
+
|
|
9
|
+
.. warning:: Breaking changes:
|
|
10
|
+
|
|
11
|
+
- The ``prefix`` parameter of
|
|
12
|
+
:meth:`Repo.commit_content <giterator.testing.Repo.commit_content>` has been renamed
|
|
13
|
+
to ``name`` and is now optional.
|
|
14
|
+
|
|
15
|
+
- Added :func:`read`, which replays the history of a repository as a series of
|
|
16
|
+
:class:`Giteration` snapshots, either one per commit or taken on a schedule such as
|
|
17
|
+
:data:`daily`.
|
|
18
|
+
|
|
19
|
+
- Added :func:`write`, which turns a series of :class:`Giteration` snapshots into commits,
|
|
20
|
+
useful for turning dated copies of a project, such as backups, into version history.
|
|
21
|
+
Combining it with :func:`read` allows a repository's history to be resampled.
|
|
22
|
+
|
|
23
|
+
- The ``giterator`` command line tool has grown ``pack`` and ``unpack`` commands for
|
|
24
|
+
moving between files with dates in their names and commits in a repository.
|
|
25
|
+
|
|
26
|
+
- Added :meth:`Git.log`, which returns the commits in a repository as :class:`Commit`
|
|
27
|
+
instances, giving structured access to the hash, author, committer, dates and full
|
|
28
|
+
message of each commit.
|
|
29
|
+
|
|
30
|
+
- :meth:`Repo.commit_content <giterator.testing.Repo.commit_content>` no longer needs a
|
|
31
|
+
file name, and the content and commit message can now be given when a test cares
|
|
32
|
+
about them.
|
|
33
|
+
|
|
34
|
+
- The documentation has been substantially expanded, and every example in it is now
|
|
35
|
+
executed as part of the test suite.
|
|
36
|
+
|
|
37
|
+
- Fixed a bug where :meth:`Git.clone` failed when the source repository was given as a
|
|
38
|
+
relative path.
|
|
39
|
+
|
|
40
|
+
- Fixed a bug where calling a :class:`Git` instance with environment variable overrides,
|
|
41
|
+
as :meth:`Git.commit` does when given explicit dates, ran ``git`` with only those
|
|
42
|
+
variables rather than the full process environment.
|
|
43
|
+
|
|
44
|
+
0.4.0 (4 Jul 2026)
|
|
45
|
+
------------------
|
|
46
|
+
|
|
47
|
+
- Moved to a `uv`__-based, ``pyproject.toml``-driven project layout, with ``main`` replacing
|
|
48
|
+
``master`` as the default git branch.
|
|
49
|
+
|
|
50
|
+
__ https://docs.astral.sh/uv/
|
|
51
|
+
|
|
52
|
+
- :meth:`Repo.clone <giterator.testing.Repo.clone>` now always ensures a user is configured in
|
|
53
|
+
the clone, whether specified explicitly, inherited from the source repo, or falling back to
|
|
54
|
+
the same default as :meth:`Repo.make <giterator.testing.Repo.make>`, so commits made in
|
|
55
|
+
clones no longer depend on the git config of the machine the tests are running on.
|
|
56
|
+
|
|
57
|
+
- :meth:`Git.init` can now pin the name of the initial branch, and
|
|
58
|
+
:meth:`Repo.make <giterator.testing.Repo.make>` does so by default, using ``main``, so branch
|
|
59
|
+
names in test repos no longer depend on the git config of the machine the tests are running on.
|
|
60
|
+
|
|
61
|
+
0.3.0 (4 Feb 2026)
|
|
62
|
+
------------------
|
|
63
|
+
|
|
64
|
+
- General refresh.
|
|
65
|
+
|
|
66
|
+
- Add ``short`` parameter to methods that return commit hashes,
|
|
67
|
+
allowing the full commit hash to be returned.
|
|
68
|
+
|
|
69
|
+
0.2.0 (1 Oct 2021)
|
|
70
|
+
------------------
|
|
71
|
+
|
|
72
|
+
- Methods that create commits now return the newly-create commit hash.
|
|
73
|
+
|
|
74
|
+
0.1.0 (28 Sep 2021)
|
|
75
|
+
-------------------
|
|
76
|
+
|
|
77
|
+
- Initial release
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
.. py:currentmodule:: giterator
|
|
2
|
+
|
|
3
|
+
Using giterator
|
|
4
|
+
===============
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Installation
|
|
8
|
+
~~~~~~~~~~~~
|
|
9
|
+
|
|
10
|
+
giterator is available on the `Python Package Index`__ and can be installed
|
|
11
|
+
with any tools for managing Python environments.
|
|
12
|
+
|
|
13
|
+
__ https://pypi.org
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Normal use
|
|
17
|
+
~~~~~~~~~~
|
|
18
|
+
|
|
19
|
+
:class:`Git` wraps the command-line ``git`` tool, giving common repository
|
|
20
|
+
operations a typed Python method instead of a shell command assembled by
|
|
21
|
+
hand. Point it at an existing work tree, or create one with :meth:`Git.init`:
|
|
22
|
+
|
|
23
|
+
.. code-block:: python
|
|
24
|
+
|
|
25
|
+
from giterator import Git, User
|
|
26
|
+
|
|
27
|
+
repo = Git('path/to/repo')
|
|
28
|
+
repo.init(User('Alice', 'alice@example.com'))
|
|
29
|
+
|
|
30
|
+
Any git subcommand that doesn't have a dedicated method can still be run
|
|
31
|
+
directly; calling a :class:`Git` instance (or its :meth:`~Git.git` alias)
|
|
32
|
+
runs ``git`` in the work tree and returns its output as a string:
|
|
33
|
+
|
|
34
|
+
.. code-block:: python
|
|
35
|
+
|
|
36
|
+
repo.git('remote', 'add', 'origin', 'git@example.com:some/repo.git')
|
|
37
|
+
|
|
38
|
+
If the command fails, a :class:`GitError` is raised carrying git's own
|
|
39
|
+
output:
|
|
40
|
+
|
|
41
|
+
>>> repo.git('merge', 'no-such-branch')
|
|
42
|
+
Traceback (most recent call last):
|
|
43
|
+
...
|
|
44
|
+
giterator.git.GitError: 'git merge no-such-branch' gave return code 1:
|
|
45
|
+
<BLANKLINE>
|
|
46
|
+
merge: no-such-branch - not something we can merge
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
:meth:`Git.commit` stages everything in the work tree, including new and
|
|
50
|
+
deleted files, and commits it, optionally backdating the author and
|
|
51
|
+
committer:
|
|
52
|
+
|
|
53
|
+
.. code-block:: python
|
|
54
|
+
|
|
55
|
+
from datetime import datetime, timezone
|
|
56
|
+
|
|
57
|
+
(repo.path / 'README.rst').write_text('hello')
|
|
58
|
+
repo.commit('add readme', author_date=datetime(2020, 1, 1, tzinfo=timezone.utc))
|
|
59
|
+
|
|
60
|
+
:meth:`Git.clone` clones a repo. When the source is a :class:`Git` instance
|
|
61
|
+
with a user configured, that user is carried over to the clone unless a
|
|
62
|
+
different one is given:
|
|
63
|
+
|
|
64
|
+
.. code-block:: python
|
|
65
|
+
|
|
66
|
+
clone = Git.clone(repo, 'path/to/clone')
|
|
67
|
+
|
|
68
|
+
Branches and tags are created and listed with :meth:`Git.branch`,
|
|
69
|
+
:meth:`Git.branches`, :meth:`Git.tag` and :meth:`Git.tags`, and their commit
|
|
70
|
+
hashes looked up with :meth:`Git.branch_hashes` and :meth:`Git.tag_hashes`:
|
|
71
|
+
|
|
72
|
+
>>> repo.branch('feature')
|
|
73
|
+
>>> repo.tag('v1.0')
|
|
74
|
+
>>> repo.branches()
|
|
75
|
+
['feature', 'main']
|
|
76
|
+
>>> repo.tags()
|
|
77
|
+
['v1.0']
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
Examining history
|
|
81
|
+
~~~~~~~~~~~~~~~~~~
|
|
82
|
+
|
|
83
|
+
:meth:`Git.log` returns the commits in a repository as :class:`Commit`
|
|
84
|
+
instances, giving structured access to the hash, author, committer, dates
|
|
85
|
+
and full message of each commit. Here, after committing some docs to go
|
|
86
|
+
with the readme:
|
|
87
|
+
|
|
88
|
+
.. code-block:: python
|
|
89
|
+
|
|
90
|
+
(repo.path / 'docs').mkdir()
|
|
91
|
+
(repo.path / 'docs' / 'index.rst').write_text('welcome')
|
|
92
|
+
repo.commit('add docs', author_date=datetime(2020, 2, 1, tzinfo=timezone.utc))
|
|
93
|
+
|
|
94
|
+
>>> for commit in repo.log():
|
|
95
|
+
... print(commit.author_date, commit.author.name, commit.message)
|
|
96
|
+
2020-02-01 00:00:00+00:00 Alice add docs
|
|
97
|
+
2020-01-01 00:00:00+00:00 Alice add readme
|
|
98
|
+
|
|
99
|
+
Any options, revision ranges or paths accepted by ``git log`` can be passed
|
|
100
|
+
as strings, here reversing the order and restricting to commits that touch
|
|
101
|
+
``docs/``:
|
|
102
|
+
|
|
103
|
+
>>> for commit in repo.log('--reverse', 'docs/'):
|
|
104
|
+
... print(commit.author_date, commit.message)
|
|
105
|
+
2020-02-01 00:00:00+00:00 add docs
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
Iterating over history
|
|
109
|
+
~~~~~~~~~~~~~~~~~~~~~~
|
|
110
|
+
|
|
111
|
+
The :func:`read` function replays the history of a repository as a series of
|
|
112
|
+
snapshots taken on a schedule. Each snapshot is a :class:`Giteration` giving
|
|
113
|
+
the path to a checkout of the repository as it was at that point in time,
|
|
114
|
+
along with the revision checked out and the time of the snapshot. The
|
|
115
|
+
examples below read a repository with commits at 10:00 on 1 January, 11:00
|
|
116
|
+
on 2 January and 12:00 on 5 January 2001, all UTC:
|
|
117
|
+
|
|
118
|
+
.. invisible-code-block: python
|
|
119
|
+
|
|
120
|
+
from giterator.testing import Repo
|
|
121
|
+
|
|
122
|
+
project = Repo.make('path/to/project')
|
|
123
|
+
for prefix, day, hour in [('a', 1, 10), ('b', 2, 11), ('c', 5, 12)]:
|
|
124
|
+
project.commit_content(prefix, datetime(2001, 1, day, hour, tzinfo=timezone.utc))
|
|
125
|
+
|
|
126
|
+
>>> from giterator import daily, read
|
|
127
|
+
>>> for giteration in read('path/to/project', daily.at(16, 0)):
|
|
128
|
+
... print(giteration.at, giteration.rev)
|
|
129
|
+
2001-01-01 16:00:00+00:00 5ee580a
|
|
130
|
+
2001-01-02 16:00:00+00:00 e3a9fbb
|
|
131
|
+
2001-01-05 16:00:00+00:00 4f0b0f3
|
|
132
|
+
|
|
133
|
+
Snapshots are made by cloning the repository into a temporary location, so
|
|
134
|
+
the repository itself is never modified. Each checkout is only valid until
|
|
135
|
+
the next snapshot is requested, and is removed when iteration finishes.
|
|
136
|
+
|
|
137
|
+
The schedule can be :data:`daily`, anchored to a time of day with
|
|
138
|
+
:meth:`Every.at` as above, or any :class:`~datetime.timedelta` giving the gap
|
|
139
|
+
between snapshots. Without an anchor, the schedule ticks from the date of
|
|
140
|
+
the repository's first commit:
|
|
141
|
+
|
|
142
|
+
>>> from datetime import timedelta
|
|
143
|
+
>>> for giteration in read('path/to/project', timedelta(days=2)):
|
|
144
|
+
... print(giteration.at, giteration.rev)
|
|
145
|
+
2001-01-01 10:00:00+00:00 5ee580a
|
|
146
|
+
2001-01-03 10:00:00+00:00 e3a9fbb
|
|
147
|
+
2001-01-07 10:00:00+00:00 4f0b0f3
|
|
148
|
+
|
|
149
|
+
``start`` begins the schedule somewhere else, with the first snapshot giving
|
|
150
|
+
the repository as it stood at that point:
|
|
151
|
+
|
|
152
|
+
>>> start = datetime(2001, 1, 4, tzinfo=timezone.utc)
|
|
153
|
+
>>> for giteration in read('path/to/project', daily.at(16, 0), start=start):
|
|
154
|
+
... print(giteration.at, giteration.rev)
|
|
155
|
+
2001-01-04 16:00:00+00:00 e3a9fbb
|
|
156
|
+
2001-01-05 16:00:00+00:00 4f0b0f3
|
|
157
|
+
|
|
158
|
+
As the examples above show, points on the schedule where the repository had
|
|
159
|
+
not changed since the previous snapshot are skipped, and iteration stops
|
|
160
|
+
once the most recent commit has been seen. Pass ``skip_unchanged=False`` to
|
|
161
|
+
get exactly one snapshot per point instead, useful when whatever consumes
|
|
162
|
+
them expects evenly spaced samples:
|
|
163
|
+
|
|
164
|
+
>>> snapshots = read('path/to/project', daily.at(16, 0), skip_unchanged=False)
|
|
165
|
+
>>> for giteration in snapshots:
|
|
166
|
+
... print(giteration.at, giteration.rev)
|
|
167
|
+
2001-01-01 16:00:00+00:00 5ee580a
|
|
168
|
+
2001-01-02 16:00:00+00:00 e3a9fbb
|
|
169
|
+
2001-01-03 16:00:00+00:00 e3a9fbb
|
|
170
|
+
2001-01-04 16:00:00+00:00 e3a9fbb
|
|
171
|
+
2001-01-05 16:00:00+00:00 4f0b0f3
|
|
172
|
+
|
|
173
|
+
When no schedule is given, a snapshot is yielded for every commit, with the
|
|
174
|
+
time of each snapshot being the date of its commit:
|
|
175
|
+
|
|
176
|
+
>>> for giteration in read('path/to/project'):
|
|
177
|
+
... print(giteration.at, giteration.rev)
|
|
178
|
+
2001-01-01 10:00:00+00:00 5ee580a
|
|
179
|
+
2001-01-02 11:00:00+00:00 e3a9fbb
|
|
180
|
+
2001-01-05 12:00:00+00:00 4f0b0f3
|
|
181
|
+
|
|
182
|
+
The :func:`write` function does the reverse, turning a series of snapshots
|
|
183
|
+
into commits in a repository, which is useful when you have dated copies of
|
|
184
|
+
a project, such as backups, that you would like to turn into version
|
|
185
|
+
history:
|
|
186
|
+
|
|
187
|
+
.. invisible-code-block: python
|
|
188
|
+
|
|
189
|
+
from pathlib import Path
|
|
190
|
+
|
|
191
|
+
for day in '2001-01-01', '2001-02-01':
|
|
192
|
+
backup = Path('backups') / day
|
|
193
|
+
backup.mkdir(parents=True)
|
|
194
|
+
(backup / 'notes.txt').write_text(day)
|
|
195
|
+
|
|
196
|
+
.. code-block:: python
|
|
197
|
+
|
|
198
|
+
from datetime import datetime
|
|
199
|
+
from giterator import Giteration, write
|
|
200
|
+
|
|
201
|
+
write('path/to/new/repo', [
|
|
202
|
+
Giteration('backups/2001-01-01', datetime(2001, 1, 1)),
|
|
203
|
+
Giteration('backups/2001-02-01', datetime(2001, 2, 1)),
|
|
204
|
+
])
|
|
205
|
+
|
|
206
|
+
If the target repository does not already exist, it is created. The content
|
|
207
|
+
of each :class:`Giteration` replaces the content of the repository's work
|
|
208
|
+
tree and is committed using its ``at`` date for both the author and
|
|
209
|
+
committer dates, with the ``at`` date as the message when none is given:
|
|
210
|
+
|
|
211
|
+
>>> [commit.message for commit in Git('path/to/new/repo').log()]
|
|
212
|
+
['2001-02-01T00:00:00', '2001-01-01T00:00:00']
|
|
213
|
+
|
|
214
|
+
Since :func:`read` yields :class:`Giteration` instances and :func:`write`
|
|
215
|
+
accepts them, the two can be combined to resample a repository's history,
|
|
216
|
+
here as it stood at 4pm each day. Commit messages are preserved, as
|
|
217
|
+
:func:`read` fills in the message of each snapshot's source commit:
|
|
218
|
+
|
|
219
|
+
>>> from giterator import write
|
|
220
|
+
>>> resampled = write('path/to/resampled', read('path/to/project', daily.at(16, 0)))
|
|
221
|
+
>>> for commit in resampled.log('--reverse'):
|
|
222
|
+
... print(commit.author_date, commit.message)
|
|
223
|
+
2001-01-01 16:00:00+00:00 a commit
|
|
224
|
+
2001-01-02 16:00:00+00:00 a commit
|
|
225
|
+
2001-01-05 16:00:00+00:00 a commit
|
|
226
|
+
|
|
227
|
+
The ``giterator`` command line tool builds on the same read/write model to
|
|
228
|
+
move between dated files and commits. ``pack`` looks for files matching a
|
|
229
|
+
:meth:`~datetime.datetime.strftime` pattern, parses the date out of each
|
|
230
|
+
file's name, and commits them oldest first under the name on the right of
|
|
231
|
+
the mapping:
|
|
232
|
+
|
|
233
|
+
.. code-block:: bash
|
|
234
|
+
|
|
235
|
+
giterator pack --repo path/to/repo 'downloads/foo-%Y-%m-%d.csv:foo.csv'
|
|
236
|
+
|
|
237
|
+
``unpack`` does the reverse, copying files matching the glob pattern on the
|
|
238
|
+
left of the mapping to the path produced by formatting each commit's date
|
|
239
|
+
with the pattern on the right:
|
|
240
|
+
|
|
241
|
+
.. code-block:: bash
|
|
242
|
+
|
|
243
|
+
giterator unpack --repo path/to/repo '*.csv:downloads/foo-%Y-%m-%d.csv'
|
|
244
|
+
|
|
245
|
+
If any of the paths involved contain a colon, ``--sep`` changes the
|
|
246
|
+
separator used in the mapping.
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
Testing
|
|
250
|
+
~~~~~~~
|
|
251
|
+
|
|
252
|
+
:class:`~giterator.testing.Repo` is a :class:`Git` subclass built for use in
|
|
253
|
+
automated tests. It configures a user and initial branch name that don't
|
|
254
|
+
depend on the git configuration of the machine running the tests, so the
|
|
255
|
+
same test behaves the same way in every environment, including CI.
|
|
256
|
+
|
|
257
|
+
The usual pattern is a pytest fixture that makes a fresh repo in a temporary
|
|
258
|
+
directory for each test:
|
|
259
|
+
|
|
260
|
+
.. code-block:: python
|
|
261
|
+
|
|
262
|
+
from pathlib import Path
|
|
263
|
+
|
|
264
|
+
import pytest
|
|
265
|
+
from giterator.testing import Repo
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@pytest.fixture()
|
|
269
|
+
def repo(tmp_path: Path) -> Repo:
|
|
270
|
+
return Repo.make(tmp_path / 'repo')
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_something(repo: Repo) -> None:
|
|
274
|
+
repo.commit_content('data')
|
|
275
|
+
commit = repo.log()[0]
|
|
276
|
+
assert commit.message == 'a commit'
|
|
277
|
+
|
|
278
|
+
.. invisible-code-block: python
|
|
279
|
+
|
|
280
|
+
from sybil.testing import run_pytest
|
|
281
|
+
|
|
282
|
+
run_pytest(test_something, fixtures=[repo])
|
|
283
|
+
|
|
284
|
+
repo = Repo.make(tmp_path / 'sample')
|
|
285
|
+
|
|
286
|
+
As ``test_something`` shows, :meth:`~giterator.testing.Repo.commit_content`
|
|
287
|
+
makes a commit in a single call: it writes a file, defaulting to
|
|
288
|
+
``sample.txt`` with content derived from its name, and commits with an
|
|
289
|
+
automatically increasing timestamp, so a test can create a string of
|
|
290
|
+
commits without inventing a file name, content, or dates by hand. When a
|
|
291
|
+
test does care about those, or about the commit message, they can all be
|
|
292
|
+
given, and the commit can be placed on a new branch or tagged:
|
|
293
|
+
|
|
294
|
+
.. code-block:: python
|
|
295
|
+
|
|
296
|
+
from datetime import datetime
|
|
297
|
+
|
|
298
|
+
repo.commit_content('a')
|
|
299
|
+
repo.commit_content('b', datetime(2021, 6, 1))
|
|
300
|
+
repo.commit_content('c', content='some specific content')
|
|
301
|
+
repo.commit_content('d', message='a specific message')
|
|
302
|
+
repo.commit_content('e', tag='v1.0')
|
|
303
|
+
repo.commit_content('f', branch='feature')
|
|
304
|
+
|
|
305
|
+
That leaves the work tree with one file per call:
|
|
306
|
+
|
|
307
|
+
>>> sorted(path.name for path in repo.path.iterdir())
|
|
308
|
+
['.git', 'a', 'b', 'c', 'd', 'e', 'f']
|
|
309
|
+
|
|
310
|
+
:meth:`~giterator.testing.Repo.make` is the usual way to create a
|
|
311
|
+
:class:`~giterator.testing.Repo`.
|
|
312
|
+
A :class:`User` and branch name can be given if the defaults, ``Giterator
|
|
313
|
+
<giterator@example.com>`` and ``main``, don't suit a particular test:
|
|
314
|
+
|
|
315
|
+
.. code-block:: python
|
|
316
|
+
|
|
317
|
+
from giterator import User
|
|
318
|
+
|
|
319
|
+
repo = Repo.make(
|
|
320
|
+
tmp_path / 'repo', user=User('Alice', 'alice@example.com'), branch='trunk'
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
:meth:`~giterator.testing.Repo.clone` works like :meth:`Git.clone`, but
|
|
324
|
+
ensures the clone always has a user configured, even when the source has
|
|
325
|
+
none, falling back to the same default as
|
|
326
|
+
:meth:`~giterator.testing.Repo.make`:
|
|
327
|
+
|
|
328
|
+
.. code-block:: python
|
|
329
|
+
|
|
330
|
+
clone = Repo.clone(repo, tmp_path / 'clone')
|
|
331
|
+
|
|
332
|
+
When a test needs full control over the files in a commit, write them and
|
|
333
|
+
use :meth:`~giterator.testing.Repo.commit`. It works like
|
|
334
|
+
:meth:`Git.commit`, except that when ``commit_date`` is omitted it defaults
|
|
335
|
+
to ``author_date``, so one date is enough to pin both of a commit's
|
|
336
|
+
timestamps:
|
|
337
|
+
|
|
338
|
+
.. code-block:: python
|
|
339
|
+
|
|
340
|
+
(repo.path / 'content.txt').write_text('content')
|
|
341
|
+
repo.commit('a commit', datetime(2020, 1, 1))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "giterator"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "1.0.0"
|
|
4
4
|
description = "Python tools for doing git things."
|
|
5
5
|
readme = "README.rst"
|
|
6
6
|
authors = [{ name = "Chris Withers", email = "chris@withers.org" }]
|
|
@@ -29,6 +29,7 @@ dev = [
|
|
|
29
29
|
"pytest>=8",
|
|
30
30
|
"pytest-cov>=6",
|
|
31
31
|
"ruff>=0.9",
|
|
32
|
+
"sybil>=10.1",
|
|
32
33
|
"testfixtures>=11",
|
|
33
34
|
]
|
|
34
35
|
docs = [
|
|
@@ -45,7 +46,7 @@ packages = ["src/giterator"]
|
|
|
45
46
|
|
|
46
47
|
[tool.pytest.ini_options]
|
|
47
48
|
addopts = "-p no:doctest"
|
|
48
|
-
testpaths = ["tests"]
|
|
49
|
+
testpaths = ["tests", "docs"]
|
|
49
50
|
|
|
50
51
|
[tool.mypy]
|
|
51
52
|
strict = true
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from argparse import ArgumentParser, Namespace
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from glob import glob
|
|
4
|
+
from os import makedirs
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from re import sub
|
|
7
|
+
from shutil import copy2
|
|
8
|
+
|
|
9
|
+
from .git import Git
|
|
10
|
+
from .iterate import read
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def split_mapping(mapping: str, sep: str) -> tuple[str, str]:
|
|
14
|
+
source, found, target = mapping.partition(sep)
|
|
15
|
+
if not found:
|
|
16
|
+
raise SystemExit(f'mapping must contain {sep!r}')
|
|
17
|
+
return source, target
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Command:
|
|
21
|
+
def add_args(self, parser: ArgumentParser) -> None:
|
|
22
|
+
parser.add_argument('--repo', type=Path, required=True, help='The repo to work with.')
|
|
23
|
+
parser.add_argument('--sep', default=':', help='The separator used in the mapping.')
|
|
24
|
+
parser.add_argument('mapping', help='A source and target, separated by --sep.')
|
|
25
|
+
|
|
26
|
+
def __call__(self, args: Namespace) -> None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Pack(Command):
|
|
30
|
+
"""
|
|
31
|
+
Commit dated files matching a strftime pattern to a repo.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __call__(self, args: Namespace) -> None:
|
|
35
|
+
pattern, name = split_mapping(args.mapping, args.sep)
|
|
36
|
+
pattern = str(Path(pattern).expanduser())
|
|
37
|
+
repo = Git(args.repo.expanduser())
|
|
38
|
+
if not (repo.path / '.git').exists():
|
|
39
|
+
repo.init()
|
|
40
|
+
found = []
|
|
41
|
+
for source in glob(sub('%[a-zA-Z]', '*', pattern)):
|
|
42
|
+
try:
|
|
43
|
+
at = datetime.strptime(source, pattern)
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
found.append((at, source))
|
|
47
|
+
for at, source in sorted(found):
|
|
48
|
+
copy2(source, repo.path / name)
|
|
49
|
+
rev = repo.commit(Path(source).name, author_date=at, commit_date=at, allow_empty=True)
|
|
50
|
+
print(rev, source)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Unpack(Command):
|
|
54
|
+
"""
|
|
55
|
+
Write files matching a glob pattern to dated copies for each commit in a repo.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __call__(self, args: Namespace) -> None:
|
|
59
|
+
pattern, target = split_mapping(args.mapping, args.sep)
|
|
60
|
+
target = str(Path(target).expanduser())
|
|
61
|
+
for giteration in read(args.repo.expanduser()):
|
|
62
|
+
assert giteration.at is not None
|
|
63
|
+
for path in sorted(giteration.path.glob(pattern)):
|
|
64
|
+
if '.git' in path.parts:
|
|
65
|
+
continue
|
|
66
|
+
dest = Path(giteration.at.strftime(target))
|
|
67
|
+
makedirs(dest.parent, exist_ok=True)
|
|
68
|
+
copy2(path, dest)
|
|
69
|
+
print(dest)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_args() -> Namespace:
|
|
73
|
+
parser = ArgumentParser()
|
|
74
|
+
subparsers = parser.add_subparsers(dest='command')
|
|
75
|
+
subparsers.required = True
|
|
76
|
+
for command_class in Command.__subclasses__():
|
|
77
|
+
command_parser = subparsers.add_parser(
|
|
78
|
+
command_class.__name__.lower(), help=command_class.__doc__
|
|
79
|
+
)
|
|
80
|
+
command = command_class()
|
|
81
|
+
command.add_args(command_parser)
|
|
82
|
+
command_parser.set_defaults(command=command)
|
|
83
|
+
return parser.parse_args()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> None:
|
|
87
|
+
args = parse_args()
|
|
88
|
+
args.command(args)
|