cafs-cache-cdn-client 1.0.5__py3-none-any.whl

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.
@@ -0,0 +1,142 @@
1
+ import asyncio
2
+ import functools
3
+ from collections.abc import Awaitable, Callable
4
+ from os.path import normpath
5
+ from pathlib import Path
6
+ from typing import Any, Self, TypeVar
7
+
8
+ import aiofiles.os as aio_os
9
+
10
+ from cafs_cache_cdn_client.cafs import CAFSClient
11
+ from cafs_cache_cdn_client.file_utils import (
12
+ LocalFile,
13
+ compare_file_lists,
14
+ set_file_stat,
15
+ walk,
16
+ )
17
+ from cafs_cache_cdn_client.repo import RepoClient
18
+
19
+ __all__ = ('CacheCdnClient',)
20
+
21
+
22
+ CAFS_SERVER_ROOT = '/cache'
23
+
24
+
25
+ T = TypeVar('T')
26
+
27
+
28
+ def needs_cafs_client(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
29
+ @functools.wraps(func)
30
+ async def wrapper(self: 'CacheCdnClient', *args: Any, **kwargs: Any) -> T:
31
+ await self._init_cafs_client()
32
+ return await func(self, *args, **kwargs)
33
+
34
+ return wrapper
35
+
36
+
37
+ class CacheCdnClient:
38
+ _cafs_client: CAFSClient | None = None
39
+ _repo_client: RepoClient
40
+
41
+ __connection_per_cafs_server: int
42
+ __cafs_client_lock = asyncio.Lock()
43
+
44
+ def __init__(self, server: str, connection_per_cafs_server: int = 1) -> None:
45
+ self._repo_client = RepoClient(server)
46
+ self.__connection_per_cafs_server = connection_per_cafs_server
47
+
48
+ async def _init_cafs_client(self) -> None:
49
+ async with self.__cafs_client_lock:
50
+ if self._cafs_client:
51
+ return
52
+ blob_urls = await self._repo_client.get_blob_urls()
53
+ self._cafs_client = await CAFSClient(
54
+ CAFS_SERVER_ROOT,
55
+ blob_urls,
56
+ connection_per_server=self.__connection_per_cafs_server,
57
+ ).__aenter__()
58
+
59
+ @needs_cafs_client
60
+ async def push(
61
+ self,
62
+ repo: str,
63
+ ref: str,
64
+ directory: Path | str,
65
+ ttl_hours: int = 0,
66
+ comment: str | None = None,
67
+ ) -> None:
68
+ if isinstance(directory, str):
69
+ directory = Path(directory)
70
+ if not directory.is_dir():
71
+ raise ValueError(f'{directory} is not a directory')
72
+ files = walk(directory)
73
+ hashes = await self._cafs_client.stream_batch(
74
+ [directory / file.path for file in files]
75
+ )
76
+ await self._repo_client.post_ref_info(
77
+ repo,
78
+ ref,
79
+ {
80
+ 'archive': False,
81
+ 'ttl': ttl_hours * 60 * 60 * 10**9,
82
+ 'comment': comment,
83
+ 'files': [
84
+ {
85
+ 'blob': blob,
86
+ 'path': file.path.as_posix(),
87
+ 'mtime': file.mtime,
88
+ 'mode': file.mode,
89
+ }
90
+ for blob, file in zip(hashes, files)
91
+ ],
92
+ },
93
+ )
94
+
95
+ async def check(self, repo: str, ref: str) -> bool:
96
+ return await self._repo_client.is_ref_exist(repo, ref)
97
+
98
+ async def delete(self, repo: str, ref: str) -> None:
99
+ await self._repo_client.delete_ref(repo, ref)
100
+
101
+ async def attach(self, repo: str, ref: str, file_path: Path) -> None:
102
+ await self._repo_client.attach_file(repo, ref, file_path)
103
+
104
+ @needs_cafs_client
105
+ async def pull(self, repo: str, ref: str, directory: Path | str) -> None:
106
+ if isinstance(directory, str):
107
+ directory = Path(directory)
108
+ await aio_os.makedirs(directory, exist_ok=True)
109
+ ref_info = await self._repo_client.get_ref_info(repo, ref)
110
+ remote_files = [
111
+ LocalFile(
112
+ path=Path(normpath(file['path'])),
113
+ mtime=file['mtime'],
114
+ mode=file['mode'],
115
+ blob=file['blob'],
116
+ )
117
+ for file in ref_info['files']
118
+ ]
119
+ local_files = walk(directory)
120
+ to_remove, to_add, to_update = await compare_file_lists(
121
+ local_files, remote_files, directory
122
+ )
123
+ for file in to_remove:
124
+ await aio_os.unlink(directory / file.path)
125
+ if to_add:
126
+ await self._cafs_client.pull_batch(
127
+ [(file.blob, directory / file.path) for file in to_add]
128
+ )
129
+ for file in to_add + to_update:
130
+ set_file_stat(file, directory)
131
+
132
+ async def tag(self, repo: str, ref: str, tag: str) -> None:
133
+ await self._repo_client.tag_ref(repo, ref, tag)
134
+
135
+ async def __aenter__(self) -> Self:
136
+ return self
137
+
138
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
139
+ async with self.__cafs_client_lock:
140
+ if not self._cafs_client:
141
+ return
142
+ await self._cafs_client.__aexit__(exc_type, exc_val, exc_tb)
@@ -0,0 +1,96 @@
1
+ import os
2
+ import sys
3
+ import time
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from cafs_cache_cdn_client.cafs.blob.hash_ import calc_hash_file
8
+
9
+ __all__ = (
10
+ 'LocalFile',
11
+ 'walk',
12
+ 'compare_file_lists',
13
+ 'set_file_stat',
14
+ )
15
+
16
+
17
+ if sys.platform == 'win32':
18
+
19
+ def is_same_mtime(
20
+ t1: int,
21
+ t2: int,
22
+ ) -> bool:
23
+ return t1 // 100 == t2 // 100
24
+ else:
25
+
26
+ def is_same_mtime(
27
+ t1: int,
28
+ t2: int,
29
+ ) -> bool:
30
+ return t1 == t2
31
+
32
+
33
+ @dataclass
34
+ class LocalFile:
35
+ path: Path
36
+ mtime: int
37
+ mode: int
38
+ blob: str | None = None
39
+
40
+
41
+ def walk(directory: Path) -> list[LocalFile]:
42
+ results = []
43
+ for root, _, files in directory.walk():
44
+ for file in files:
45
+ file_ = root / file
46
+ file_stat = file_.stat()
47
+ results.append(
48
+ LocalFile(
49
+ path=file_.relative_to(directory),
50
+ mtime=file_stat.st_mtime_ns,
51
+ mode=file_stat.st_mode & 0o777,
52
+ )
53
+ )
54
+ return results
55
+
56
+
57
+ async def compare_file_lists(
58
+ src_files: list[LocalFile],
59
+ dst_files: list[LocalFile],
60
+ directory: Path,
61
+ ) -> tuple[list[LocalFile], list[LocalFile], list[LocalFile]]:
62
+ src_files_dict = {file.path: file for file in src_files}
63
+ dst_files_dict = {file.path: file for file in dst_files}
64
+ to_remove = src_files_dict.keys() - dst_files_dict.keys()
65
+ to_add = dst_files_dict.keys() - src_files_dict.keys()
66
+ to_update = set()
67
+ for same_file in src_files_dict.keys() & dst_files_dict.keys():
68
+ if not dst_files_dict[same_file].blob:
69
+ to_remove.add(same_file)
70
+ to_add.add(same_file)
71
+ continue
72
+ if not src_files_dict[same_file].blob:
73
+ src_files_dict[same_file].blob = await calc_hash_file(directory / same_file)
74
+ if src_files_dict[same_file].blob != dst_files_dict[same_file].blob:
75
+ to_remove.add(same_file)
76
+ to_add.add(same_file)
77
+ continue
78
+ if (
79
+ not is_same_mtime(
80
+ src_files_dict[same_file].mtime, dst_files_dict[same_file].mtime
81
+ )
82
+ or src_files_dict[same_file].mode != dst_files_dict[same_file].mode
83
+ ):
84
+ to_update.add(same_file)
85
+
86
+ return (
87
+ [src_files_dict[file] for file in to_remove],
88
+ [dst_files_dict[file] for file in to_add],
89
+ [src_files_dict[file] for file in to_update],
90
+ )
91
+
92
+
93
+ def set_file_stat(file: LocalFile, directory: Path) -> None:
94
+ file_ = directory / file.path
95
+ file_.chmod(file.mode)
96
+ os.utime(file_, ns=(time.time_ns(), file.mtime))
@@ -0,0 +1,3 @@
1
+ from .client import RepoClient
2
+
3
+ __all__ = ('RepoClient',)
@@ -0,0 +1,102 @@
1
+ from collections.abc import Iterable
2
+ from http import HTTPMethod
3
+ from pathlib import Path
4
+ from typing import Any, cast
5
+ from urllib.parse import quote, urljoin
6
+
7
+ import aiofiles
8
+ import aiohttp
9
+ import yarl
10
+
11
+ import cafs_cache_cdn_client.repo.datatypes as dt
12
+
13
+ __all__ = ('RepoClient',)
14
+
15
+
16
+ class RepoClient:
17
+ server_base_url: str
18
+
19
+ def __init__(self, server: str) -> None:
20
+ self.server_base_url = server
21
+
22
+ async def _request(
23
+ self,
24
+ endpoint: str,
25
+ params: Iterable[tuple[str, str]] | None = None,
26
+ method: HTTPMethod = HTTPMethod.GET,
27
+ data: dict | bytes | aiohttp.FormData | None = None,
28
+ headers: dict[str, str] | None = None,
29
+ json_request: bool = True,
30
+ ) -> Any:
31
+ if params:
32
+ endpoint += '?' + '&'.join(f'{k}={quote(v)}' for k, v in params)
33
+ headers = headers.copy() if headers else {}
34
+ if json_request:
35
+ headers['Content-Type'] = 'application/json'
36
+ url_ = yarl.URL(urljoin(self.server_base_url, endpoint), encoded=True)
37
+ if json_request:
38
+ data_arg = {'json': data}
39
+ else:
40
+ data_arg = {'data': data}
41
+ async with aiohttp.ClientSession(
42
+ headers=headers, requote_redirect_url=False
43
+ ) as session:
44
+ async with session.request(
45
+ method, url_, **data_arg, raise_for_status=True
46
+ ) as resp:
47
+ if resp.headers.get('Content-Type') == 'application/json':
48
+ return await resp.json()
49
+ return await resp.read()
50
+
51
+ async def get_settings(self) -> dt.SettingsResponse:
52
+ return cast(
53
+ dt.SettingsResponse, await self._request('/settings', method=HTTPMethod.GET)
54
+ )
55
+
56
+ async def get_blob_urls(self) -> list[str]:
57
+ settings = await self.get_settings()
58
+ return settings['blob_urls']
59
+
60
+ async def is_ref_exist(self, repo: str, ref: str) -> bool:
61
+ try:
62
+ await self._request(f'/repository/{repo}/{ref}', method=HTTPMethod.HEAD)
63
+ return True
64
+ except aiohttp.ClientResponseError as e:
65
+ if e.status == 404:
66
+ return False
67
+ raise
68
+
69
+ async def delete_ref(self, repo: str, ref: str) -> None:
70
+ try:
71
+ await self._request(f'/repository/{repo}/{ref}', method=HTTPMethod.DELETE)
72
+ except aiohttp.ClientResponseError as e:
73
+ if e.status == 404:
74
+ return
75
+ raise
76
+
77
+ async def get_ref_info(self, repo: str, ref: str) -> dt.RefInfoResponse:
78
+ return cast(
79
+ dt.RefInfoResponse,
80
+ await self._request(f'/repository/{repo}/{ref}', method=HTTPMethod.GET),
81
+ )
82
+
83
+ async def tag_ref(self, repo: str, ref: str, tag: str) -> None:
84
+ await self._request(
85
+ f'/repository/{repo}/{ref}/tag', method=HTTPMethod.PUT, data={'tag': tag}
86
+ )
87
+
88
+ async def post_ref_info(self, repo: str, ref: str, data: dt.RefInfoBody) -> None:
89
+ await self._request(
90
+ f'/repository/{repo}/{ref}', method=HTTPMethod.POST, data=data
91
+ )
92
+
93
+ async def attach_file(self, repo: str, ref: str, file_path: Path) -> None:
94
+ async with aiofiles.open(file_path, 'rb') as f:
95
+ form_data = aiohttp.FormData()
96
+ form_data.add_field('file', f, filename=file_path.name)
97
+ await self._request(
98
+ f'/repository/{repo}/{ref}/attach',
99
+ method=HTTPMethod.POST,
100
+ data=form_data,
101
+ json_request=False,
102
+ )
@@ -0,0 +1,34 @@
1
+ from typing import TypedDict
2
+
3
+ __all__ = (
4
+ 'SettingsResponse',
5
+ 'RefInfoResponse',
6
+ 'RefInfoBody',
7
+ 'FileMetadata',
8
+ )
9
+
10
+
11
+ class FileMetadata(TypedDict):
12
+ blob: str
13
+ path: str
14
+ mtime: int
15
+ mode: int
16
+
17
+
18
+ class SettingsResponse(TypedDict):
19
+ blob_urls: list[str]
20
+
21
+
22
+ class RefInfoResponse(TypedDict):
23
+ revision: int
24
+ archive: bool
25
+ ttl: int
26
+ updated: int
27
+ files: list[FileMetadata]
28
+
29
+
30
+ class RefInfoBody(TypedDict):
31
+ archive: bool
32
+ ttl: int
33
+ comment: str | None
34
+ files: list[FileMetadata]
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.3
2
+ Name: cafs-cache-cdn-client
3
+ Version: 1.0.5
4
+ Summary: Async Cache CDN client implementation
5
+ Keywords: cafs,cache
6
+ Author: Konstantin Belov
7
+ Author-email: k.belov@gaijin.team
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Dist: aiofiles
16
+ Requires-Dist: aiohttp
17
+ Requires-Dist: blake3
18
+ Requires-Dist: zstandard
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Cache CDN Client
22
+ A Python client library for interacting with the Cache CDN service based on CAFS, allowing efficient pushing and pulling of cached content.
23
+ ## Installation
24
+ ``` bash
25
+ pip install cafs-cache-cdn-client
26
+ ```
27
+ ## Features
28
+ - Asynchronous API for high-performance operations
29
+ - Push local directories to cache
30
+ - Pull cached content to local directories
31
+ - Check existence of cached references
32
+ - Tag references for easier access
33
+ - Attach additional files to existing references
34
+ - Delete references when no longer needed
35
+
36
+ ## Usage Example
37
+ ```python
38
+ import asyncio
39
+ import logging
40
+ from pathlib import Path
41
+ from cafs_cache_cdn_client import CacheCdnClient
42
+
43
+ # Configure logging to see detailed operation information
44
+ logging.basicConfig(level=logging.DEBUG)
45
+
46
+
47
+ async def main():
48
+ # Initialize the client with the server URL
49
+ # The connection_per_cafs_server parameter controls concurrency
50
+ client = CacheCdnClient(
51
+ 'http://cache-server.example.com:8300',
52
+ connection_per_cafs_server=10
53
+ )
54
+
55
+ # Use as an async context manager to ensure proper resource cleanup
56
+ async with client:
57
+ # Push a local directory to cache with a 2-hour TTL
58
+ await client.push('project_name', 'build_artifacts',
59
+ '/path/to/build/output', ttl_hours=2,
60
+ comment='Build artifacts from CI run #123')
61
+
62
+ # Check if a reference exists
63
+ exists = await client.check('project_name', 'build_artifacts')
64
+ print(f"Reference exists: {exists}")
65
+
66
+ # Pull cached content to a local directory
67
+ await client.pull('project_name', 'build_artifacts',
68
+ '/path/to/destination')
69
+
70
+ # Tag a reference for easier access later
71
+ await client.tag('project_name', 'build_artifacts', 'latest_stable')
72
+
73
+ # Attach an additional file to an existing reference
74
+ await client.attach('project_name', 'build_artifacts',
75
+ Path('/path/to/metadata.json'))
76
+
77
+ # Delete a reference when no longer needed
78
+ await client.delete('project_name', 'old_artifacts')
79
+
80
+
81
+ # Run the example
82
+ if __name__ == '__main__':
83
+ asyncio.run(main())
84
+ ```
85
+
86
+ ## API Reference
87
+ ### `CacheCdnClient`
88
+ - **Constructor**: `CacheCdnClient(server: str, connection_per_cafs_server: int = 1)`
89
+ - `server`: URL of the cache server
90
+ - `connection_per_cafs_server`: Number of concurrent connections per CAFS server
91
+
92
+ - **Methods**:
93
+ - `push(repo: str, ref: str, directory: Path | str, ttl_hours: int = 0, comment: str | None = None)` - Push a local directory to cache
94
+ - `pull(repo: str, ref: str, directory: Path | str)` - Pull cached content to a local directory
95
+ - `check(repo: str, ref: str) -> bool` - Check if a reference exists
96
+ - `tag(repo: str, ref: str, tag: str)` - Create a tag for a reference
97
+ - `attach(repo: str, ref: str, file_path: Path)` - Attach a file to an existing reference
98
+ - `delete(repo: str, ref: str)` - Delete a reference
99
+
@@ -0,0 +1,18 @@
1
+ cafs_cache_cdn_client/__init__.py,sha256=N-Gpb6OMWWaEruRwbPjkrZ0Uta8gsQFmq1yFhGcvtcc,35
2
+ cafs_cache_cdn_client/cafs/README.md,sha256=bcn9CEX4O74jY4kRi2oJb37oi55aneyQxDji76SU4Aw,2554
3
+ cafs_cache_cdn_client/cafs/__init__.py,sha256=Ae2WHVqgI9dEuWG5cX-8nbuUH_wpW-5dACt6JGM3FcE,123
4
+ cafs_cache_cdn_client/cafs/blob/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ cafs_cache_cdn_client/cafs/blob/hash_.py,sha256=tI4qenyHnEEtKnNUOq4a25L1GnlKjoieyPGQDoz14rA,736
6
+ cafs_cache_cdn_client/cafs/blob/package.py,sha256=pOMF5Dw42APHqIlRfcs1YPscqjcbO32Z35aEG0SqoHo,5865
7
+ cafs_cache_cdn_client/cafs/blob/utils.py,sha256=tqAOvPWIL-sOwXwPiH9Kbsqn5VekJtlf6UO8V9TTtwM,1021
8
+ cafs_cache_cdn_client/cafs/client.py,sha256=TJxwc_PFFmSeCxfTjybh--NrCDzuzuxh3Si42Lwm4Tc,18232
9
+ cafs_cache_cdn_client/cafs/exceptions.py,sha256=I4E3lFrQ_ysR8gDIh3E0Gz4OMihfLD6vLj_KwKzxnTU,654
10
+ cafs_cache_cdn_client/cafs/types.py,sha256=q7zqDQ8yKRn-HRSsXDuzawmikAEcyoYQ-_p2WmuDFy4,324
11
+ cafs_cache_cdn_client/client.py,sha256=d7Dw2U9h8dPYRDPu0mbnXc62SlLy4ruvQk2Alj3cVk8,4584
12
+ cafs_cache_cdn_client/file_utils.py,sha256=kDaak0n3emLIn0DXB02yWFkUhYxuLyTmH93WPszKEUQ,2642
13
+ cafs_cache_cdn_client/repo/__init__.py,sha256=JzCxKznnLV7WElJuznG_jL2s9tYPecJL5uJRnPZsW5M,58
14
+ cafs_cache_cdn_client/repo/client.py,sha256=FEiLefNmvJKwA2gz6dj2gcvt3uKRzLqBARGy4dvXP98,3545
15
+ cafs_cache_cdn_client/repo/datatypes.py,sha256=aTlzFoEibfAOQTqcsg-RmFjnwenS_rBCEMKtN6glOhY,531
16
+ cafs_cache_cdn_client-1.0.5.dist-info/METADATA,sha256=m-dhAfuTdQz97XW280baoDd2vZDxEQW0dtJF-eLqbFI,3679
17
+ cafs_cache_cdn_client-1.0.5.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
18
+ cafs_cache_cdn_client-1.0.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any