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.
Files changed (45) hide show
  1. {giterator-0.4.0 → giterator-1.0.0}/AGENTS.md +9 -0
  2. {giterator-0.4.0 → giterator-1.0.0}/PKG-INFO +15 -2
  3. giterator-1.0.0/README.rst +25 -0
  4. giterator-1.0.0/conftest.py +34 -0
  5. giterator-1.0.0/docs/api.rst +28 -0
  6. giterator-1.0.0/docs/changes.rst +77 -0
  7. giterator-1.0.0/docs/use.rst +341 -0
  8. {giterator-0.4.0 → giterator-1.0.0}/pyproject.toml +3 -2
  9. giterator-1.0.0/src/giterator/__init__.py +15 -0
  10. giterator-1.0.0/src/giterator/cli.py +88 -0
  11. {giterator-0.4.0 → giterator-1.0.0}/src/giterator/git.py +86 -6
  12. giterator-1.0.0/src/giterator/iterate.py +194 -0
  13. giterator-1.0.0/src/giterator/testing.py +97 -0
  14. giterator-1.0.0/tests/test_cli.py +131 -0
  15. {giterator-0.4.0 → giterator-1.0.0}/tests/test_git.py +103 -3
  16. giterator-1.0.0/tests/test_iterate.py +300 -0
  17. {giterator-0.4.0 → giterator-1.0.0}/tests/test_testing.py +15 -8
  18. giterator-0.4.0/README.rst +0 -12
  19. giterator-0.4.0/docs/api.rst +0 -12
  20. giterator-0.4.0/docs/changes.rst +0 -39
  21. giterator-0.4.0/docs/use.rst +0 -13
  22. giterator-0.4.0/src/giterator/__init__.py +0 -7
  23. giterator-0.4.0/src/giterator/cli.py +0 -34
  24. giterator-0.4.0/src/giterator/testing.py +0 -77
  25. giterator-0.4.0/tests/test_cli.py +0 -45
  26. {giterator-0.4.0 → giterator-1.0.0}/.github/workflows/ci.yml +0 -0
  27. {giterator-0.4.0 → giterator-1.0.0}/.github/workflows/release.yml +0 -0
  28. {giterator-0.4.0 → giterator-1.0.0}/.gitignore +0 -0
  29. {giterator-0.4.0 → giterator-1.0.0}/.python-version +0 -0
  30. {giterator-0.4.0 → giterator-1.0.0}/.readthedocs.yml +0 -0
  31. {giterator-0.4.0 → giterator-1.0.0}/CLAUDE.md +0 -0
  32. {giterator-0.4.0 → giterator-1.0.0}/LICENSE.rst +0 -0
  33. {giterator-0.4.0 → giterator-1.0.0}/docs/Makefile +0 -0
  34. {giterator-0.4.0 → giterator-1.0.0}/docs/conf.py +0 -0
  35. {giterator-0.4.0 → giterator-1.0.0}/docs/development.rst +0 -0
  36. {giterator-0.4.0 → giterator-1.0.0}/docs/index.rst +0 -0
  37. {giterator-0.4.0 → giterator-1.0.0}/docs/license.rst +0 -0
  38. {giterator-0.4.0 → giterator-1.0.0}/happy.sh +0 -0
  39. {giterator-0.4.0 → giterator-1.0.0}/src/giterator/__main__.py +0 -0
  40. {giterator-0.4.0 → giterator-1.0.0}/src/giterator/clock.py +0 -0
  41. {giterator-0.4.0 → giterator-1.0.0}/src/giterator/py.typed +0 -0
  42. {giterator-0.4.0 → giterator-1.0.0}/src/giterator/typing.py +0 -0
  43. {giterator-0.4.0 → giterator-1.0.0}/tests/__init__.py +0 -0
  44. {giterator-0.4.0 → giterator-1.0.0}/tests/conftest.py +0 -0
  45. {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.4.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.4.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,15 @@
1
+ from .git import Commit, Git, GitError, User
2
+ from .iterate import Every, Giteration, daily, read, write
3
+
4
+
5
+ __all__ = [
6
+ 'Git',
7
+ 'User',
8
+ 'Commit',
9
+ 'GitError',
10
+ 'Every',
11
+ 'daily',
12
+ 'Giteration',
13
+ 'read',
14
+ 'write',
15
+ ]
@@ -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)