fluidattacks_core_git 12.0.0__tar.gz → 12.1.1__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 (32) hide show
  1. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/.gitignore +4 -0
  2. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/PKG-INFO +2 -2
  3. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/download_repo.py +27 -7
  4. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/import-linter.cfg +1 -11
  5. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/pyproject.toml +3 -2
  6. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/tests/test_git.py +76 -1
  7. fluidattacks_core_git-12.1.1/tests/test_remote.py +106 -0
  8. fluidattacks_core_git-12.1.1/tests/test_upload_repo.py +559 -0
  9. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/uv.lock +20 -5
  10. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/.envrc +0 -0
  11. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/build/default.nix +0 -0
  12. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/build/filter.nix +0 -0
  13. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/flake.lock +0 -0
  14. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/flake.nix +0 -0
  15. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/__init__.py +0 -0
  16. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/classes.py +0 -0
  17. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/clone.py +0 -0
  18. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/codecommit_utils.py +0 -0
  19. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/constants.py +0 -0
  20. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/delete_files.py +0 -0
  21. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/download_file.py +0 -0
  22. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/https_utils.py +0 -0
  23. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/py.typed +0 -0
  24. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/remote.py +0 -0
  25. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/show_file.py +0 -0
  26. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/ssh_utils.py +0 -0
  27. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/upload_repo.py +0 -0
  28. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/utils.py +0 -0
  29. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/fluidattacks_core/git/warp.py +0 -0
  30. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/mypy.ini +0 -0
  31. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/ruff.toml +0 -0
  32. {fluidattacks_core_git-12.0.0 → fluidattacks_core_git-12.1.1}/tests/__init__.py +0 -0
@@ -1,5 +1,9 @@
1
+ dist/
2
+ dist-*/
3
+ /.cargo-cache
1
4
  target/
2
5
  .direnv/
3
6
  .venv/
4
7
  .vscode/settings.json
5
8
  .zed/settings.json
9
+ *.so
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fluidattacks_core_git
3
- Version: 12.0.0
3
+ Version: 12.1.1
4
4
  Summary: Fluid Attacks Core Git Library
5
5
  Author-email: Development <development@fluidattacks.com>
6
6
  License: MPL-2.0
@@ -16,7 +16,7 @@ Requires-Dist: aiohttp>=3.10.0
16
16
  Requires-Dist: anyio>=4.7.0
17
17
  Requires-Dist: boto3>=1.34
18
18
  Requires-Dist: botocore>=1.40.18
19
- Requires-Dist: fluidattacks-core-http>=12.0.0
19
+ Requires-Dist: fluidattacks-core-http>=12.1.0
20
20
  Requires-Dist: gitpython>=3.1.41
21
21
  Requires-Dist: pathspec>=0.12.1
22
22
  Requires-Dist: urllib3>=2.6.1
@@ -46,13 +46,7 @@ def remove_symlinks_in_directory(directory: str) -> None:
46
46
  Path(file_path).unlink(missing_ok=True)
47
47
 
48
48
 
49
- def _reset_repo_sync(repo_path: str) -> bool:
50
- try:
51
- Path.cwd()
52
- except OSError:
53
- LOGGER.exception("Failed to get the working directory: %s", repo_path)
54
- os.chdir(repo_path)
55
-
49
+ def _mark_safe_directory(repo_path: str) -> None:
56
50
  try:
57
51
  Git().execute(
58
52
  [
@@ -67,6 +61,16 @@ def _reset_repo_sync(repo_path: str) -> bool:
67
61
  except GitError:
68
62
  LOGGER.exception("Failed to add safe directory %s", repo_path)
69
63
 
64
+
65
+ def _reset_repo_sync(repo_path: str) -> bool:
66
+ try:
67
+ Path.cwd()
68
+ except OSError:
69
+ LOGGER.exception("Failed to get the working directory: %s", repo_path)
70
+ os.chdir(repo_path)
71
+
72
+ _mark_safe_directory(repo_path)
73
+
70
74
  try:
71
75
  repo = Repo(repo_path)
72
76
  repo.git.reset("--hard", "HEAD")
@@ -114,7 +118,16 @@ async def download_repo_from_s3(
114
118
  git_ignore: list[str] | None = None,
115
119
  *,
116
120
  download_buffer_size: int = DEFAULT_DOWNLOAD_BUFFER_SIZE,
121
+ checkout: bool = True,
117
122
  ) -> bool:
123
+ """Download the repository archived in s3 at `download_url`.
124
+
125
+ The archive holds only `.git`, so the working tree is restored with a
126
+ `git reset --hard`. Pass `checkout=False` to keep just the history: peak
127
+ disk stays at the size of the archive plus `.git`, which is what a
128
+ consumer that walks commits needs. `git_ignore` prunes working-tree files
129
+ and has no effect on a history-only download.
130
+ """
118
131
  await anyio.Path(destination_path.parent).mkdir(parents=True, exist_ok=True)
119
132
  with tempfile.TemporaryDirectory(prefix="fluidattacks_", ignore_cleanup_errors=True) as tmpdir:
120
133
  tmp_path = Path(tmpdir)
@@ -139,6 +152,13 @@ async def download_repo_from_s3(
139
152
  )
140
153
  return False
141
154
 
155
+ if not checkout:
156
+ # Nothing was extracted but `.git`, so there is no working tree to
157
+ # reset or prune. Callers still read the repository through git, which
158
+ # refuses a directory it considers foreign.
159
+ await anyio.to_thread.run_sync(_mark_safe_directory, str(destination_path))
160
+ return True
161
+
142
162
  if not await reset_repo(str(destination_path)):
143
163
  shutil.rmtree(destination_path, ignore_errors=True)
144
164
  return False
@@ -1,17 +1,7 @@
1
1
  [importlinter]
2
- root_package = fluidattacks_core
2
+ root_package = fluidattacks_core.git
3
3
  include_external_packages = True
4
4
 
5
- [importlinter:contract:portion]
6
- name = Single Portion
7
- type = layers
8
- containers =
9
- fluidattacks_core
10
- exhaustive = true
11
- layers =
12
- git
13
- http
14
-
15
5
  [importlinter:contract:dag]
16
6
  name = Direct Acyclic Graph
17
7
  type = layers
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fluidattacks_core_git"
3
- version = "12.0.0"
3
+ version = "12.1.1"
4
4
  description = "Fluid Attacks Core Git Library"
5
5
  authors = [{ name = "Development", email = "development@fluidattacks.com" }]
6
6
  license = { text = "MPL-2.0" }
@@ -19,7 +19,7 @@ dependencies = [
19
19
  "anyio>=4.7.0",
20
20
  "boto3>=1.34",
21
21
  "botocore>=1.40.18",
22
- "fluidattacks-core-http>=12.0.0",
22
+ "fluidattacks-core-http>=12.1.0",
23
23
  "GitPython>=3.1.41",
24
24
  "pathspec>=0.12.1",
25
25
  "urllib3>=2.6.1",
@@ -32,6 +32,7 @@ dev = [
32
32
  "deptry>=0.23.1",
33
33
  "import-linter==2.11",
34
34
  "mypy==1.19.1",
35
+ "pytest-asyncio>=1.1.0",
35
36
  "pytest-cov>=7.0.0",
36
37
  "pytest==9.0.3",
37
38
  "ruff==0.15.0",
@@ -1,3 +1,5 @@
1
+ import shutil
2
+ import tarfile
1
3
  import tempfile
2
4
  from pathlib import Path
3
5
 
@@ -6,7 +8,7 @@ from git.repo import Repo
6
8
  from pathspec import PathSpec
7
9
  from pathspec import util as pathspec_util
8
10
 
9
- from fluidattacks_core.git import rebase
11
+ from fluidattacks_core.git import download_repo, download_repo_from_s3, rebase
10
12
  from fluidattacks_core.git.delete_files import delete_out_of_scope_files
11
13
  from fluidattacks_core.git.download_repo import remove_symlinks_in_directory
12
14
 
@@ -171,3 +173,76 @@ def test_rebase_preserves_ascii_renamed_path() -> None:
171
173
  assert result.path == renamed
172
174
  assert result.line == 1
173
175
  assert result.rev == rev_b
176
+
177
+
178
+ def _archive_git_dir(repo_path: Path, archive_path: Path, name: str) -> None:
179
+ # Mimics `_create_tar_archive`: the archive uploaded to s3 carries `.git`
180
+ # and no tracked file.
181
+ with tarfile.open(archive_path, "w:gz") as tar_handler:
182
+ tar_handler.add(str(repo_path / ".git"), arcname=f"{name}/.git", recursive=True)
183
+
184
+
185
+ def _committed_repo(path: Path, tracked: str) -> str:
186
+ repo = _init_repo(path)
187
+ (path / tracked).write_text("content\n", encoding="utf-8")
188
+ repo.index.add([tracked])
189
+ revision = repo.index.commit("add tracked file").hexsha
190
+ repo.close()
191
+ return revision
192
+
193
+
194
+ @pytest.fixture
195
+ def s3_archive(
196
+ tmp_path: Path,
197
+ monkeypatch: pytest.MonkeyPatch,
198
+ ) -> str:
199
+ # `git config --global` writes to $HOME, so keep it inside the test.
200
+ monkeypatch.setenv("HOME", str(tmp_path / "home"))
201
+ (tmp_path / "home").mkdir()
202
+
203
+ source = tmp_path / "source"
204
+ source.mkdir()
205
+ revision = _committed_repo(source, "tracked.txt")
206
+ archive = tmp_path / "repo.tar.gz"
207
+ _archive_git_dir(source, archive, "source")
208
+ shutil.rmtree(source)
209
+
210
+ async def _fake_download(
211
+ *,
212
+ url: str, # noqa: ARG001
213
+ destination_path: str,
214
+ download_buffer_size: int, # noqa: ARG001
215
+ ) -> bool:
216
+ shutil.copyfile(archive, destination_path)
217
+ return True
218
+
219
+ monkeypatch.setattr(download_repo, "download_file", _fake_download)
220
+ return revision
221
+
222
+
223
+ @pytest.mark.asyncio
224
+ @pytest.mark.usefixtures("s3_archive")
225
+ async def test_download_repo_from_s3_restores_working_tree(tmp_path: Path) -> None:
226
+ destination = tmp_path / "groups" / "group" / "root"
227
+
228
+ assert await download_repo_from_s3("https://s3/repo.tar.gz", destination)
229
+
230
+ assert (destination / "tracked.txt").is_file()
231
+
232
+
233
+ @pytest.mark.asyncio
234
+ async def test_download_repo_from_s3_without_checkout_keeps_only_history(
235
+ tmp_path: Path,
236
+ s3_archive: str,
237
+ ) -> None:
238
+ destination = tmp_path / "groups" / "group" / "root"
239
+
240
+ assert await download_repo_from_s3("https://s3/repo.tar.gz", destination, checkout=False)
241
+
242
+ # The point of the flag: the history is complete and readable, and not one
243
+ # byte of the working tree was written.
244
+ repo = Repo(destination)
245
+ assert repo.head.commit.hexsha == s3_archive
246
+ repo.close()
247
+ assert not (destination / "tracked.txt").exists()
248
+ assert [entry.name for entry in destination.iterdir()] == [".git"]
@@ -0,0 +1,106 @@
1
+ from unittest.mock import AsyncMock, patch
2
+
3
+ import pytest
4
+
5
+ from fluidattacks_core.git.remote import ls_remote
6
+
7
+ _SSH_PATH = "fluidattacks_core.git.remote.call_ssh_ls_remote"
8
+ _CODECOMMIT_PATH = "fluidattacks_core.git.remote.call_codecommit_ls_remote"
9
+ _HTTPS_PATH = "fluidattacks_core.git.remote.call_https_ls_remote"
10
+
11
+ _SHA = "0" * 40
12
+ _CREDENTIAL = "a-token"
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_ls_remote_with_a_credential_key_goes_over_ssh() -> None:
17
+ with (
18
+ patch(_SSH_PATH, AsyncMock(return_value=(_SHA, None))) as ssh,
19
+ patch(_CODECOMMIT_PATH, AsyncMock()) as codecommit,
20
+ patch(_HTTPS_PATH, AsyncMock()) as https,
21
+ ):
22
+ sha, error = await ls_remote(
23
+ repo_url="git@github.com:fluidattacks/universe",
24
+ repo_branch="main",
25
+ credential_key="a-key",
26
+ arn="an-arn",
27
+ org_external_id="an-id",
28
+ )
29
+
30
+ assert (sha, error) == (_SHA, None)
31
+ ssh.assert_awaited_once_with("git@github.com:fluidattacks/universe", "a-key", "main")
32
+ codecommit.assert_not_awaited()
33
+ https.assert_not_awaited()
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_ls_remote_with_an_arn_and_an_external_id_goes_over_codecommit() -> None:
38
+ with (
39
+ patch(_SSH_PATH, AsyncMock()) as ssh,
40
+ patch(_CODECOMMIT_PATH, AsyncMock(return_value=(_SHA, None))) as codecommit,
41
+ patch(_HTTPS_PATH, AsyncMock()) as https,
42
+ ):
43
+ sha, error = await ls_remote(
44
+ repo_url="codecommit://a-repo",
45
+ repo_branch="main",
46
+ arn="an-arn",
47
+ org_external_id="an-id",
48
+ follow_redirects=True,
49
+ )
50
+
51
+ assert (sha, error) == (_SHA, None)
52
+ codecommit.assert_awaited_once_with(
53
+ "codecommit://a-repo",
54
+ "an-arn",
55
+ "main",
56
+ org_external_id="an-id",
57
+ follow_redirects=True,
58
+ )
59
+ ssh.assert_not_awaited()
60
+ https.assert_not_awaited()
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_ls_remote_without_ssh_or_codecommit_inputs_goes_over_https() -> None:
65
+ with (
66
+ patch(_SSH_PATH, AsyncMock()) as ssh,
67
+ patch(_CODECOMMIT_PATH, AsyncMock()) as codecommit,
68
+ patch(_HTTPS_PATH, AsyncMock(return_value=(_SHA, None))) as https,
69
+ ):
70
+ sha, error = await ls_remote(
71
+ repo_url="https://github.com/fluidattacks/universe",
72
+ repo_branch="main",
73
+ token=_CREDENTIAL,
74
+ provider="GITHUB",
75
+ is_pat=True,
76
+ )
77
+
78
+ assert (sha, error) == (_SHA, None)
79
+ https.assert_awaited_once_with(
80
+ repo_url="https://github.com/fluidattacks/universe",
81
+ user=None,
82
+ password=None,
83
+ token=_CREDENTIAL,
84
+ branch="main",
85
+ provider="GITHUB",
86
+ is_pat=True,
87
+ follow_redirects=False,
88
+ )
89
+ ssh.assert_not_awaited()
90
+ codecommit.assert_not_awaited()
91
+
92
+
93
+ @pytest.mark.asyncio
94
+ async def test_ls_remote_forwards_the_error_from_the_transport() -> None:
95
+ with (
96
+ patch(_SSH_PATH, AsyncMock()),
97
+ patch(_CODECOMMIT_PATH, AsyncMock()),
98
+ patch(_HTTPS_PATH, AsyncMock(return_value=(None, "Repository not found"))),
99
+ ):
100
+ sha, error = await ls_remote(
101
+ repo_url="https://github.com/fluidattacks/nope",
102
+ repo_branch="main",
103
+ )
104
+
105
+ assert sha is None
106
+ assert error == "Repository not found"
@@ -0,0 +1,559 @@
1
+ import math
2
+ import tarfile
3
+ import tempfile
4
+ from pathlib import Path
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ import pytest
8
+
9
+ from fluidattacks_core.git.constants import MAX_S3_PARTS, MULTIPART_PART_SIZE
10
+ from fluidattacks_core.git.upload_repo import (
11
+ MultipartCallbacks,
12
+ PartUploadConfig,
13
+ UploadPartResult,
14
+ _complete_with_retry,
15
+ _compute_part_size,
16
+ _create_tar_archive,
17
+ _do_part_upload,
18
+ _file_chunks,
19
+ _initiate_with_retry,
20
+ _upload_all_parts,
21
+ _upload_part_with_retry,
22
+ upload_repo_to_s3,
23
+ )
24
+
25
+ _SLEEP_PATH = "fluidattacks_core.git.upload_repo.anyio.sleep"
26
+ _PART_RETRY_PATH = "fluidattacks_core.git.upload_repo._upload_part_with_retry"
27
+ _SESSION_PATH = "fluidattacks_core.git.upload_repo.aiohttp.ClientSession"
28
+ _THRESHOLD_PATH = "fluidattacks_core.git.upload_repo.MULTIPART_THRESHOLD"
29
+
30
+
31
+ def _fake_git_repo(base: Path) -> Path:
32
+ repo = base / "repo"
33
+ git_dir = repo / ".git"
34
+ git_dir.mkdir(parents=True)
35
+ (git_dir / "HEAD").write_text("ref: refs/heads/main\n")
36
+ return repo
37
+
38
+
39
+ def _fake_file(base: Path, content: bytes = b"x" * 512) -> Path:
40
+ path = base / "data.bin"
41
+ path.write_bytes(content)
42
+ return path
43
+
44
+
45
+ def _make_mock_session(status: int = 200, etag: str = '"etag-value"') -> MagicMock:
46
+ mock_response = MagicMock()
47
+ mock_response.status = status
48
+ mock_response.headers = {"ETag": etag}
49
+ mock_response.text = AsyncMock(return_value="error body")
50
+
51
+ mock_put_cm = MagicMock()
52
+ mock_put_cm.__aenter__ = AsyncMock(return_value=mock_response)
53
+ mock_put_cm.__aexit__ = AsyncMock(return_value=None)
54
+
55
+ mock_session = MagicMock()
56
+ mock_session.__aenter__ = AsyncMock(return_value=mock_session)
57
+ mock_session.__aexit__ = AsyncMock(return_value=None)
58
+ mock_session.put = MagicMock(return_value=mock_put_cm)
59
+
60
+ return mock_session
61
+
62
+
63
+ @pytest.mark.asyncio
64
+ async def test_create_tar_archive_produces_valid_gzip_with_git_entries() -> None:
65
+ with tempfile.TemporaryDirectory() as tmp:
66
+ repo_path = _fake_git_repo(Path(tmp))
67
+ output = Path(tmp) / "archive.tar.gz"
68
+
69
+ result = await _create_tar_archive(repo_path, output, "my-repo")
70
+
71
+ assert result is True
72
+ assert output.exists()
73
+ with tarfile.open(output, "r:gz") as tf:
74
+ names = tf.getnames()
75
+ assert all(name.startswith("my-repo/.git") for name in names)
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_create_tar_archive_returns_false_when_git_dir_missing() -> None:
80
+ with tempfile.TemporaryDirectory() as tmp:
81
+ repo_path = Path(tmp) / "no-git-here"
82
+ repo_path.mkdir()
83
+ output = Path(tmp) / "archive.tar.gz"
84
+
85
+ result = await _create_tar_archive(repo_path, output, "my-repo")
86
+
87
+ assert result is False
88
+ assert not output.exists()
89
+
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_create_tar_archive_returns_false_on_io_error() -> None:
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ repo_path = _fake_git_repo(Path(tmp))
95
+ invalid_output = Path(tmp) / "nonexistent_dir" / "archive.tar.gz"
96
+
97
+ result = await _create_tar_archive(repo_path, invalid_output, "my-repo")
98
+
99
+ assert result is False
100
+
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_file_chunks_yields_complete_file_content() -> None:
104
+ with tempfile.TemporaryDirectory() as tmp:
105
+ content = b"abcdefgh" * 10_000
106
+ file_path = Path(tmp) / "test.bin"
107
+ file_path.write_bytes(content)
108
+
109
+ received = [chunk async for chunk in _file_chunks(file_path)]
110
+
111
+ assert b"".join(received) == content
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_file_chunks_empty_file_yields_nothing() -> None:
116
+ with tempfile.TemporaryDirectory() as tmp:
117
+ file_path = Path(tmp) / "empty.bin"
118
+ file_path.write_bytes(b"")
119
+
120
+ received = [chunk async for chunk in _file_chunks(file_path)]
121
+
122
+ assert received == []
123
+
124
+
125
+ @pytest.mark.asyncio
126
+ async def test_do_part_upload_returns_etag_on_200() -> None:
127
+ mock_session = _make_mock_session(status=200, etag='"abc-etag"')
128
+
129
+ result = await _do_part_upload(mock_session, "https://example.com/part", b"data")
130
+
131
+ assert result == '"abc-etag"'
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_do_part_upload_preserves_etag_quotes_for_s3_completion() -> None:
136
+ mock_session = _make_mock_session(status=200, etag='"quoted-etag"')
137
+
138
+ result = await _do_part_upload(mock_session, "https://example.com/part", b"data")
139
+
140
+ assert result == '"quoted-etag"'
141
+ assert result is not None
142
+ assert result.startswith('"')
143
+ assert result.endswith('"')
144
+
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_do_part_upload_returns_none_on_non_200_status() -> None:
148
+ mock_session = _make_mock_session(status=403, etag="")
149
+
150
+ result = await _do_part_upload(mock_session, "https://example.com/part", b"data")
151
+
152
+ assert result is None
153
+
154
+
155
+ @pytest.mark.asyncio
156
+ async def test_initiate_with_retry_returns_upload_id_on_first_attempt() -> None:
157
+ initiate = AsyncMock(return_value="upload-id-123")
158
+
159
+ result = await _initiate_with_retry(initiate)
160
+
161
+ assert result == "upload-id-123"
162
+ assert initiate.call_count == 1
163
+
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_initiate_with_retry_retries_after_none_and_succeeds() -> None:
167
+ initiate = AsyncMock(side_effect=[None, None, "upload-id-ok"])
168
+
169
+ with patch(_SLEEP_PATH, new_callable=AsyncMock):
170
+ result = await _initiate_with_retry(initiate)
171
+
172
+ assert result == "upload-id-ok"
173
+ assert initiate.call_count == 3
174
+
175
+
176
+ @pytest.mark.asyncio
177
+ async def test_initiate_with_retry_returns_none_after_all_attempts_fail() -> None:
178
+ initiate = AsyncMock(return_value=None)
179
+
180
+ with patch(_SLEEP_PATH, new_callable=AsyncMock):
181
+ result = await _initiate_with_retry(initiate)
182
+
183
+ assert result is None
184
+ assert initiate.call_count == 3
185
+
186
+
187
+ @pytest.mark.asyncio
188
+ async def test_complete_with_retry_returns_true_on_first_success() -> None:
189
+ complete = AsyncMock(return_value=True)
190
+
191
+ result = await _complete_with_retry("uid", [], complete)
192
+
193
+ assert result is True
194
+ assert complete.call_count == 1
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ async def test_complete_with_retry_retries_on_false_then_succeeds() -> None:
199
+ complete = AsyncMock(side_effect=[False, False, True])
200
+
201
+ with patch(_SLEEP_PATH, new_callable=AsyncMock):
202
+ result = await _complete_with_retry("uid", [], complete)
203
+
204
+ assert result is True
205
+ assert complete.call_count == 3
206
+
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_complete_with_retry_returns_false_after_all_attempts_fail() -> None:
210
+ complete = AsyncMock(return_value=False)
211
+
212
+ with patch(_SLEEP_PATH, new_callable=AsyncMock):
213
+ result = await _complete_with_retry("uid", [], complete)
214
+
215
+ assert result is False
216
+ assert complete.call_count == 3
217
+
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_upload_part_with_retry_returns_result_on_first_success() -> None:
221
+ with tempfile.TemporaryDirectory() as tmp:
222
+ file_path = _fake_file(Path(tmp))
223
+ config = PartUploadConfig(
224
+ file_path=file_path,
225
+ part_number=2,
226
+ start_byte=0,
227
+ end_byte=512,
228
+ upload_id="upload-id",
229
+ )
230
+ get_part_url = AsyncMock(return_value="https://s3.example.com/part")
231
+
232
+ with (
233
+ patch(_SESSION_PATH, return_value=_make_mock_session()),
234
+ patch(
235
+ "fluidattacks_core.git.upload_repo._do_part_upload",
236
+ new_callable=AsyncMock,
237
+ return_value="part-etag",
238
+ ),
239
+ ):
240
+ result = await _upload_part_with_retry(config, get_part_url)
241
+
242
+ assert result is not None
243
+ assert result.part_number == 2
244
+ assert result.etag == "part-etag"
245
+
246
+
247
+ @pytest.mark.asyncio
248
+ async def test_upload_part_with_retry_succeeds_on_second_attempt() -> None:
249
+ with tempfile.TemporaryDirectory() as tmp:
250
+ file_path = _fake_file(Path(tmp))
251
+ config = PartUploadConfig(
252
+ file_path=file_path,
253
+ part_number=1,
254
+ start_byte=0,
255
+ end_byte=512,
256
+ upload_id="upload-id",
257
+ )
258
+ get_part_url = AsyncMock(return_value="https://s3.example.com/part")
259
+
260
+ with (
261
+ patch(_SESSION_PATH, return_value=_make_mock_session()),
262
+ patch(
263
+ "fluidattacks_core.git.upload_repo._do_part_upload",
264
+ new_callable=AsyncMock,
265
+ side_effect=[None, "recovered-etag"],
266
+ ),
267
+ patch(_SLEEP_PATH, new_callable=AsyncMock),
268
+ ):
269
+ result = await _upload_part_with_retry(config, get_part_url)
270
+
271
+ assert result is not None
272
+ assert result.etag == "recovered-etag"
273
+
274
+
275
+ @pytest.mark.asyncio
276
+ async def test_upload_part_with_retry_returns_none_after_all_attempts_exhausted() -> None:
277
+ with tempfile.TemporaryDirectory() as tmp:
278
+ file_path = _fake_file(Path(tmp))
279
+ config = PartUploadConfig(
280
+ file_path=file_path,
281
+ part_number=1,
282
+ start_byte=0,
283
+ end_byte=512,
284
+ upload_id="upload-id",
285
+ )
286
+ get_part_url = AsyncMock(return_value="https://s3.example.com/part")
287
+
288
+ with (
289
+ patch(_SESSION_PATH, return_value=_make_mock_session()),
290
+ patch(
291
+ "fluidattacks_core.git.upload_repo._do_part_upload",
292
+ new_callable=AsyncMock,
293
+ return_value=None,
294
+ ),
295
+ patch(_SLEEP_PATH, new_callable=AsyncMock),
296
+ ):
297
+ result = await _upload_part_with_retry(config, get_part_url)
298
+
299
+ assert result is None
300
+
301
+
302
+ @pytest.mark.asyncio
303
+ async def test_upload_part_with_retry_skips_attempt_when_presigned_url_empty() -> None:
304
+ with tempfile.TemporaryDirectory() as tmp:
305
+ file_path = _fake_file(Path(tmp))
306
+ config = PartUploadConfig(
307
+ file_path=file_path,
308
+ part_number=1,
309
+ start_byte=0,
310
+ end_byte=512,
311
+ upload_id="upload-id",
312
+ )
313
+ get_part_url = AsyncMock(return_value="")
314
+
315
+ with (
316
+ patch(_SESSION_PATH, return_value=_make_mock_session()),
317
+ patch(_SLEEP_PATH, new_callable=AsyncMock),
318
+ ):
319
+ result = await _upload_part_with_retry(config, get_part_url)
320
+
321
+ assert result is None
322
+
323
+
324
+ @pytest.mark.asyncio
325
+ async def test_upload_all_parts_collects_results_indexed_by_part_number() -> None:
326
+ with tempfile.TemporaryDirectory() as tmp:
327
+ file_path = _fake_file(Path(tmp), content=b"x" * 100)
328
+
329
+ def _make_result(config: PartUploadConfig, _: object) -> UploadPartResult:
330
+ return UploadPartResult(
331
+ part_number=config.part_number, etag=f"etag-{config.part_number}"
332
+ )
333
+
334
+ with patch(_PART_RETRY_PATH, new_callable=AsyncMock, side_effect=_make_result):
335
+ result = await _upload_all_parts(
336
+ file_path=file_path,
337
+ file_size=100,
338
+ upload_id="uid",
339
+ get_part_url=AsyncMock(return_value="url"),
340
+ part_size=100,
341
+ )
342
+
343
+ assert result is not None
344
+ assert result[0].part_number == 1
345
+ assert result[0].etag == "etag-1"
346
+
347
+
348
+ @pytest.mark.asyncio
349
+ async def test_upload_all_parts_returns_none_when_any_part_fails() -> None:
350
+ with tempfile.TemporaryDirectory() as tmp:
351
+ file_path = _fake_file(Path(tmp), content=b"x" * 100)
352
+
353
+ with patch(_PART_RETRY_PATH, new_callable=AsyncMock, return_value=None):
354
+ result = await _upload_all_parts(
355
+ file_path=file_path,
356
+ file_size=100,
357
+ upload_id="uid",
358
+ get_part_url=AsyncMock(return_value="url"),
359
+ part_size=100,
360
+ )
361
+
362
+ assert result is None
363
+
364
+
365
+ @pytest.mark.asyncio
366
+ async def test_upload_repo_to_s3_returns_false_for_nonexistent_path() -> None:
367
+ result = await upload_repo_to_s3(
368
+ repo_path=Path("/does/not/exist"),
369
+ archive_name="test",
370
+ upload_url="https://example.com",
371
+ )
372
+
373
+ assert result is False
374
+
375
+
376
+ @pytest.mark.asyncio
377
+ async def test_upload_repo_to_s3_returns_false_when_upload_url_missing_for_small_file() -> None:
378
+ with tempfile.TemporaryDirectory() as tmp:
379
+ repo_path = _fake_git_repo(Path(tmp))
380
+
381
+ result = await upload_repo_to_s3(repo_path=repo_path, archive_name="test")
382
+
383
+ assert result is False
384
+
385
+
386
+ @pytest.mark.asyncio
387
+ async def test_upload_repo_to_s3_returns_false_when_multipart_missing_for_large_file() -> None:
388
+ with tempfile.TemporaryDirectory() as tmp:
389
+ repo_path = _fake_git_repo(Path(tmp))
390
+
391
+ with patch(_THRESHOLD_PATH, 0):
392
+ result = await upload_repo_to_s3(
393
+ repo_path=repo_path,
394
+ archive_name="test",
395
+ upload_url="https://example.com",
396
+ )
397
+
398
+ assert result is False
399
+
400
+
401
+ @pytest.mark.asyncio
402
+ async def test_upload_repo_to_s3_single_part_returns_true_on_200() -> None:
403
+ with tempfile.TemporaryDirectory() as tmp:
404
+ repo_path = _fake_git_repo(Path(tmp))
405
+
406
+ with patch(_SESSION_PATH, return_value=_make_mock_session(status=200)):
407
+ result = await upload_repo_to_s3(
408
+ repo_path=repo_path,
409
+ archive_name="test-repo",
410
+ upload_url="https://s3.example.com/upload",
411
+ )
412
+
413
+ assert result is True
414
+
415
+
416
+ @pytest.mark.asyncio
417
+ async def test_upload_repo_to_s3_single_part_returns_false_on_non_200() -> None:
418
+ with tempfile.TemporaryDirectory() as tmp:
419
+ repo_path = _fake_git_repo(Path(tmp))
420
+
421
+ with patch(_SESSION_PATH, return_value=_make_mock_session(status=500)):
422
+ result = await upload_repo_to_s3(
423
+ repo_path=repo_path,
424
+ archive_name="test-repo",
425
+ upload_url="https://s3.example.com/upload",
426
+ )
427
+
428
+ assert result is False
429
+
430
+
431
+ @pytest.mark.asyncio
432
+ async def test_upload_repo_to_s3_multipart_path_returns_true_on_success() -> None:
433
+ with tempfile.TemporaryDirectory() as tmp:
434
+ repo_path = _fake_git_repo(Path(tmp))
435
+
436
+ multipart = MultipartCallbacks(
437
+ initiate_upload=AsyncMock(return_value="upload-id"),
438
+ get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
439
+ complete_upload=AsyncMock(return_value=True),
440
+ abort_upload=AsyncMock(),
441
+ )
442
+
443
+ with (
444
+ patch(_THRESHOLD_PATH, 0),
445
+ patch(
446
+ _PART_RETRY_PATH,
447
+ new_callable=AsyncMock,
448
+ return_value=UploadPartResult(part_number=1, etag="etag-1"),
449
+ ),
450
+ ):
451
+ result = await upload_repo_to_s3(
452
+ repo_path=repo_path,
453
+ archive_name="test-repo",
454
+ multipart=multipart,
455
+ )
456
+
457
+ assert result is True
458
+
459
+
460
+ @pytest.mark.asyncio
461
+ async def test_upload_repo_to_s3_multipart_calls_abort_when_parts_fail() -> None:
462
+ with tempfile.TemporaryDirectory() as tmp:
463
+ repo_path = _fake_git_repo(Path(tmp))
464
+
465
+ abort_mock = AsyncMock()
466
+ multipart = MultipartCallbacks(
467
+ initiate_upload=AsyncMock(return_value="upload-id"),
468
+ get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
469
+ complete_upload=AsyncMock(return_value=True),
470
+ abort_upload=abort_mock,
471
+ )
472
+
473
+ with (
474
+ patch(_THRESHOLD_PATH, 0),
475
+ patch(_PART_RETRY_PATH, new_callable=AsyncMock, return_value=None),
476
+ ):
477
+ result = await upload_repo_to_s3(
478
+ repo_path=repo_path,
479
+ archive_name="test-repo",
480
+ multipart=multipart,
481
+ )
482
+
483
+ assert result is False
484
+ abort_mock.assert_called_once_with("upload-id")
485
+
486
+
487
+ @pytest.mark.asyncio
488
+ async def test_upload_repo_to_s3_multipart_calls_abort_when_complete_fails() -> None:
489
+ with tempfile.TemporaryDirectory() as tmp:
490
+ repo_path = _fake_git_repo(Path(tmp))
491
+
492
+ abort_mock = AsyncMock()
493
+ multipart = MultipartCallbacks(
494
+ initiate_upload=AsyncMock(return_value="upload-id"),
495
+ get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
496
+ complete_upload=AsyncMock(return_value=False),
497
+ abort_upload=abort_mock,
498
+ )
499
+
500
+ with (
501
+ patch(_THRESHOLD_PATH, 0),
502
+ patch(
503
+ _PART_RETRY_PATH,
504
+ new_callable=AsyncMock,
505
+ return_value=UploadPartResult(part_number=1, etag="etag-1"),
506
+ ),
507
+ patch(_SLEEP_PATH, new_callable=AsyncMock),
508
+ ):
509
+ result = await upload_repo_to_s3(
510
+ repo_path=repo_path,
511
+ archive_name="test-repo",
512
+ multipart=multipart,
513
+ )
514
+
515
+ assert result is False
516
+ abort_mock.assert_called_once_with("upload-id")
517
+
518
+
519
+ @pytest.mark.asyncio
520
+ async def test_upload_repo_to_s3_multipart_aborts_on_unexpected_exception() -> None:
521
+ with tempfile.TemporaryDirectory() as tmp:
522
+ repo_path = _fake_git_repo(Path(tmp))
523
+
524
+ abort_mock = AsyncMock()
525
+ multipart = MultipartCallbacks(
526
+ initiate_upload=AsyncMock(return_value="upload-id"),
527
+ get_part_url=AsyncMock(return_value="https://s3.example.com/part"),
528
+ complete_upload=AsyncMock(return_value=True),
529
+ abort_upload=abort_mock,
530
+ )
531
+
532
+ with (
533
+ pytest.raises(ValueError, match="unexpected callback error"),
534
+ patch(_THRESHOLD_PATH, 0),
535
+ patch(
536
+ "fluidattacks_core.git.upload_repo._upload_all_parts",
537
+ new_callable=AsyncMock,
538
+ side_effect=ValueError("unexpected callback error"),
539
+ ),
540
+ ):
541
+ await upload_repo_to_s3(
542
+ repo_path=repo_path,
543
+ archive_name="test-repo",
544
+ multipart=multipart,
545
+ )
546
+
547
+ abort_mock.assert_called_once_with("upload-id")
548
+
549
+
550
+ def test_compute_part_size_returns_default_for_small_files() -> None:
551
+ small_file = MULTIPART_PART_SIZE * 100
552
+ assert _compute_part_size(small_file) == MULTIPART_PART_SIZE
553
+
554
+
555
+ def test_compute_part_size_scales_up_for_files_exceeding_10000_parts() -> None:
556
+ huge_file = MULTIPART_PART_SIZE * (MAX_S3_PARTS + 1)
557
+ result = _compute_part_size(huge_file)
558
+ assert result > MULTIPART_PART_SIZE
559
+ assert math.ceil(huge_file / result) <= MAX_S3_PARTS
@@ -432,7 +432,7 @@ wheels = [
432
432
 
433
433
  [[package]]
434
434
  name = "fluidattacks-core-git"
435
- version = "12.0.0"
435
+ version = "12.1.1"
436
436
  source = { editable = "." }
437
437
  dependencies = [
438
438
  { name = "aiofiles" },
@@ -454,6 +454,7 @@ dev = [
454
454
  { name = "import-linter" },
455
455
  { name = "mypy" },
456
456
  { name = "pytest" },
457
+ { name = "pytest-asyncio" },
457
458
  { name = "pytest-cov" },
458
459
  { name = "ruff" },
459
460
  { name = "types-aiofiles" },
@@ -466,7 +467,7 @@ requires-dist = [
466
467
  { name = "anyio", specifier = ">=4.7.0" },
467
468
  { name = "boto3", specifier = ">=1.34" },
468
469
  { name = "botocore", specifier = ">=1.40.18" },
469
- { name = "fluidattacks-core-http", specifier = ">=12.0.0" },
470
+ { name = "fluidattacks-core-http", specifier = ">=12.1.0" },
470
471
  { name = "gitpython", specifier = ">=3.1.41" },
471
472
  { name = "pathspec", specifier = ">=0.12.1" },
472
473
  { name = "urllib3", specifier = ">=2.6.1" },
@@ -480,6 +481,7 @@ dev = [
480
481
  { name = "import-linter", specifier = "==2.11" },
481
482
  { name = "mypy", specifier = "==1.19.1" },
482
483
  { name = "pytest", specifier = "==9.0.3" },
484
+ { name = "pytest-asyncio", specifier = ">=1.1.0" },
483
485
  { name = "pytest-cov", specifier = ">=7.0.0" },
484
486
  { name = "ruff", specifier = "==0.15.0" },
485
487
  { name = "types-aiofiles", specifier = "==24.1.0.20240626" },
@@ -487,16 +489,16 @@ dev = [
487
489
 
488
490
  [[package]]
489
491
  name = "fluidattacks-core-http"
490
- version = "12.0.0"
492
+ version = "12.1.0"
491
493
  source = { registry = "https://pypi.org/simple" }
492
494
  dependencies = [
493
495
  { name = "aiohttp" },
494
496
  { name = "certifi" },
495
497
  { name = "urllib3" },
496
498
  ]
497
- sdist = { url = "https://files.pythonhosted.org/packages/cf/89/51755605b83f8ce87624badb0b54bdc5787b06c8fd47047c2c0c6cb50772/fluidattacks_core_http-12.0.0.tar.gz", hash = "sha256:5f4111394c194fb10a869f6ee0ea7d76f313c85a0dedbbc3d26f9c9d9d33aa13", size = 86512, upload-time = "2026-08-18T20:17:39.722Z" }
499
+ sdist = { url = "https://files.pythonhosted.org/packages/6c/a2/822f0a6d06cffea448cb7be1d731c028a0df6d5e657700fabd749f176f8d/fluidattacks_core_http-12.1.0.tar.gz", hash = "sha256:b3ef49420da9a3bd24e050b0b5b7a6666b1a0e7b308e97a59e871d80e27aa222", size = 89677, upload-time = "2026-08-27T19:19:54.15Z" }
498
500
  wheels = [
499
- { url = "https://files.pythonhosted.org/packages/9e/9f/42ff5b135fe9fcf30d14b08a8b68a404f9b0e7338166952ab75539911a52/fluidattacks_core_http-12.0.0-py3-none-any.whl", hash = "sha256:e6f41f6a1683fa04f0867e0867d6922be2332f776ef651bec12d56c5d18caff2", size = 3707, upload-time = "2026-08-18T20:17:41.207Z" },
501
+ { url = "https://files.pythonhosted.org/packages/c0/cd/1beca25745de6058a43ee5c417c064c42ac645990fbdfd739ec4a2fe2f33/fluidattacks_core_http-12.1.0-py3-none-any.whl", hash = "sha256:6f15e283f8ebed0fcf73cb9cd2be268f1d891c1e3e9635b6cd2323c1084f014c", size = 4403, upload-time = "2026-08-27T19:19:54.855Z" },
500
502
  ]
501
503
 
502
504
  [[package]]
@@ -1331,6 +1333,19 @@ wheels = [
1331
1333
  { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
1332
1334
  ]
1333
1335
 
1336
+ [[package]]
1337
+ name = "pytest-asyncio"
1338
+ version = "1.4.0"
1339
+ source = { registry = "https://pypi.org/simple" }
1340
+ dependencies = [
1341
+ { name = "pytest" },
1342
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
1343
+ ]
1344
+ sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
1345
+ wheels = [
1346
+ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
1347
+ ]
1348
+
1334
1349
  [[package]]
1335
1350
  name = "pytest-cov"
1336
1351
  version = "7.1.0"