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.
- cafs_cache_cdn_client/__init__.py +1 -0
- cafs_cache_cdn_client/cafs/README.md +63 -0
- cafs_cache_cdn_client/cafs/__init__.py +7 -0
- cafs_cache_cdn_client/cafs/blob/__init__.py +0 -0
- cafs_cache_cdn_client/cafs/blob/hash_.py +34 -0
- cafs_cache_cdn_client/cafs/blob/package.py +198 -0
- cafs_cache_cdn_client/cafs/blob/utils.py +37 -0
- cafs_cache_cdn_client/cafs/client.py +535 -0
- cafs_cache_cdn_client/cafs/exceptions.py +30 -0
- cafs_cache_cdn_client/cafs/types.py +19 -0
- cafs_cache_cdn_client/client.py +142 -0
- cafs_cache_cdn_client/file_utils.py +96 -0
- cafs_cache_cdn_client/repo/__init__.py +3 -0
- cafs_cache_cdn_client/repo/client.py +102 -0
- cafs_cache_cdn_client/repo/datatypes.py +34 -0
- cafs_cache_cdn_client-1.0.5.dist-info/METADATA +99 -0
- cafs_cache_cdn_client-1.0.5.dist-info/RECORD +18 -0
- cafs_cache_cdn_client-1.0.5.dist-info/WHEEL +4 -0
@@ -0,0 +1 @@
|
|
1
|
+
from .client import CacheCdnClient
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# CAFS Client
|
2
|
+
|
3
|
+
CAFS Client is a Python library that provides an asynchronous interface for interacting with CAFS servers.
|
4
|
+
|
5
|
+
More information about CAFS protocol can be found in the
|
6
|
+
[G-CVSNT](https://github.com/GaijinEntertainment/G-CVSNT/tree/master/cvsnt/cvsnt-2.5.05.3744/keyValueServer) repository.
|
7
|
+
|
8
|
+
## Usage Example
|
9
|
+
|
10
|
+
Below is a complete example demonstrating all major functionality of the CAFSClient:
|
11
|
+
|
12
|
+
```python
|
13
|
+
import asyncio
|
14
|
+
from pathlib import Path
|
15
|
+
from cafs_cache_cdn_client.cafs import CAFSClient, CompressionT
|
16
|
+
|
17
|
+
|
18
|
+
async def cafs_client_demo():
|
19
|
+
|
20
|
+
client = CAFSClient(
|
21
|
+
server_root='/data',
|
22
|
+
servers=['localhost', 'example.com:2403'],
|
23
|
+
connection_per_server=2,
|
24
|
+
connect_timeout=5.0
|
25
|
+
)
|
26
|
+
|
27
|
+
async with client:
|
28
|
+
# 1. Upload a file (stream operation)
|
29
|
+
source_file = Path('./sample.txt')
|
30
|
+
blob_hash = await client.stream(
|
31
|
+
path=source_file,
|
32
|
+
compression=CompressionT.ZSTD,
|
33
|
+
)
|
34
|
+
print(f'File uploaded with hash: {blob_hash}')
|
35
|
+
|
36
|
+
# 2. Check if the file exists on the server
|
37
|
+
exists = await client.check(blob_hash)
|
38
|
+
print(f'File exists: {exists}')
|
39
|
+
|
40
|
+
# 3. Get the file size
|
41
|
+
size = await client.size(blob_hash)
|
42
|
+
print(f'File size: {size} bytes')
|
43
|
+
|
44
|
+
# 4. Download the file (pull operation)
|
45
|
+
download_path = Path('./downloaded_sample.txt')
|
46
|
+
await client.pull(blob_hash, download_path)
|
47
|
+
|
48
|
+
if __name__ == '__main__':
|
49
|
+
asyncio.run(cafs_client_demo())
|
50
|
+
```
|
51
|
+
|
52
|
+
## Retry Mechanism
|
53
|
+
|
54
|
+
The CAFSClient implements a robust retry mechanism. This feature ensures that operations attempt to complete even if some servers or connections are unavailable:
|
55
|
+
|
56
|
+
- When `retry=True` is specified (default for most operations), the client will automatically retry the operation across all available connections in the pool.
|
57
|
+
- The client will iterate through all available connections until either:
|
58
|
+
1. The operation succeeds
|
59
|
+
2. All connections in the pool have been exhausted without success
|
60
|
+
|
61
|
+
This behavior makes the client resilient to temporary network issues or server unavailability when multiple servers are configured. For critical operations, always use the default `retry=True` setting to maximize the chances of operation success in distributed environments.
|
62
|
+
|
63
|
+
If a specific operation needs to fail immediately without attempting other connections, you can disable this behavior by setting `retry=False` when calling methods like `pull()`, `check()`, `size()`, and `stream()` of the client.
|
File without changes
|
@@ -0,0 +1,34 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
|
3
|
+
import aiofiles
|
4
|
+
from blake3 import blake3
|
5
|
+
|
6
|
+
from cafs_cache_cdn_client.cafs.types import AsyncReader
|
7
|
+
|
8
|
+
__all__ = (
|
9
|
+
'calc_hash',
|
10
|
+
'calc_hash_file',
|
11
|
+
)
|
12
|
+
|
13
|
+
DEFAULT_BUFFER_SIZE = 4 * 1024 * 1024
|
14
|
+
|
15
|
+
|
16
|
+
async def calc_hash(
|
17
|
+
reader: 'AsyncReader', buffer_size: int = DEFAULT_BUFFER_SIZE
|
18
|
+
) -> str:
|
19
|
+
hasher = blake3() # pylint: disable=not-callable
|
20
|
+
|
21
|
+
while True:
|
22
|
+
buffer = await reader.read(buffer_size)
|
23
|
+
if not buffer:
|
24
|
+
break
|
25
|
+
hasher.update(buffer)
|
26
|
+
|
27
|
+
return hasher.hexdigest()
|
28
|
+
|
29
|
+
|
30
|
+
async def calc_hash_file(
|
31
|
+
file_path: Path, buffer_size: int = DEFAULT_BUFFER_SIZE
|
32
|
+
) -> str:
|
33
|
+
async with aiofiles.open(file_path, 'rb') as f:
|
34
|
+
return await calc_hash(f, buffer_size)
|
@@ -0,0 +1,198 @@
|
|
1
|
+
import zlib
|
2
|
+
from enum import Enum
|
3
|
+
from logging import Logger, LoggerAdapter, getLogger
|
4
|
+
from typing import Protocol
|
5
|
+
|
6
|
+
try:
|
7
|
+
import zstandard as zstd
|
8
|
+
except ImportError:
|
9
|
+
zstd = None # type: ignore[assignment]
|
10
|
+
|
11
|
+
from cafs_cache_cdn_client.cafs.types import AsyncReader, AsyncWriter
|
12
|
+
|
13
|
+
__all__ = (
|
14
|
+
'CompressionT',
|
15
|
+
'Packer',
|
16
|
+
'Unpacker',
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
module_logger = getLogger(__name__)
|
21
|
+
|
22
|
+
|
23
|
+
class CompressionT(bytes, Enum):
|
24
|
+
ZSTD = b'ZSTD'
|
25
|
+
ZLIB = b'ZLIB'
|
26
|
+
NONE = b'NONE'
|
27
|
+
|
28
|
+
def __str__(self) -> str:
|
29
|
+
return self.decode('utf-8')
|
30
|
+
|
31
|
+
|
32
|
+
FULL_HEADER_SIZE = 16
|
33
|
+
COMPRESSION_HEADER_SIZE = 4
|
34
|
+
DEFAULT_CHUNK_SIZE = 16 * 1024 * 1024
|
35
|
+
|
36
|
+
|
37
|
+
class Compressor(Protocol):
|
38
|
+
def compress(self, data: bytes) -> bytes:
|
39
|
+
pass
|
40
|
+
|
41
|
+
def flush(self) -> bytes:
|
42
|
+
pass
|
43
|
+
|
44
|
+
|
45
|
+
class Decompressor(Protocol):
|
46
|
+
def decompress(self, data: bytes) -> bytes:
|
47
|
+
pass
|
48
|
+
|
49
|
+
def flush(self) -> bytes:
|
50
|
+
pass
|
51
|
+
|
52
|
+
|
53
|
+
class Packer:
|
54
|
+
logger: Logger | LoggerAdapter
|
55
|
+
chunk_size: int
|
56
|
+
|
57
|
+
_reader: 'AsyncReader'
|
58
|
+
_eof_reached: bool
|
59
|
+
_buffer: bytearray
|
60
|
+
_compressor: Compressor | None
|
61
|
+
|
62
|
+
def __init__(
|
63
|
+
self,
|
64
|
+
reader: 'AsyncReader',
|
65
|
+
compression: CompressionT = CompressionT.NONE,
|
66
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
67
|
+
logger: Logger | LoggerAdapter | None = None,
|
68
|
+
) -> None:
|
69
|
+
self._reader = reader
|
70
|
+
self._eof_reached = False
|
71
|
+
self.chunk_size = chunk_size
|
72
|
+
|
73
|
+
self._compressor = None
|
74
|
+
if compression == CompressionT.ZLIB:
|
75
|
+
self._compressor = zlib.compressobj()
|
76
|
+
elif compression == CompressionT.ZSTD:
|
77
|
+
if not zstd:
|
78
|
+
raise RuntimeError(
|
79
|
+
'ZSTD compression is not available, please install zstandard'
|
80
|
+
)
|
81
|
+
self._compressor = zstd.ZstdCompressor().compressobj()
|
82
|
+
|
83
|
+
self._buffer = bytearray(
|
84
|
+
compression + b'\x00' * (FULL_HEADER_SIZE - COMPRESSION_HEADER_SIZE)
|
85
|
+
)
|
86
|
+
self.logger = logger or module_logger
|
87
|
+
self.logger.debug('Initialized packer with compression: %s', compression)
|
88
|
+
|
89
|
+
async def read(self, size: int = -1) -> bytes:
|
90
|
+
if size == 0:
|
91
|
+
return b''
|
92
|
+
|
93
|
+
while (size > 0 and len(self._buffer) < size) and not self._eof_reached:
|
94
|
+
await self._fill_buffer()
|
95
|
+
|
96
|
+
if size < 0 or len(self._buffer) <= size:
|
97
|
+
result = bytes(self._buffer)
|
98
|
+
self._buffer.clear()
|
99
|
+
return result
|
100
|
+
|
101
|
+
result = bytes(self._buffer[:size])
|
102
|
+
self._buffer = self._buffer[size:]
|
103
|
+
return result
|
104
|
+
|
105
|
+
async def _fill_buffer(self) -> None:
|
106
|
+
chunk = await self._reader.read(self.chunk_size)
|
107
|
+
self.logger.debug('Filling buffer with chunk of %d bytes', len(chunk))
|
108
|
+
|
109
|
+
if not chunk:
|
110
|
+
self._eof_reached = True
|
111
|
+
self.logger.debug('EOF reached')
|
112
|
+
if self._compressor:
|
113
|
+
data = self._compressor.flush()
|
114
|
+
self.logger.debug('Flushing compressor: %d bytes', len(data))
|
115
|
+
self._buffer.extend(data)
|
116
|
+
return
|
117
|
+
|
118
|
+
if not self._compressor:
|
119
|
+
self._buffer.extend(chunk)
|
120
|
+
return
|
121
|
+
|
122
|
+
data = self._compressor.compress(chunk)
|
123
|
+
self.logger.debug('Got %d bytes from compressor', len(data))
|
124
|
+
self._buffer.extend(data)
|
125
|
+
|
126
|
+
|
127
|
+
class Unpacker:
|
128
|
+
logger: Logger | LoggerAdapter
|
129
|
+
chunk_size: int
|
130
|
+
|
131
|
+
_writer: 'AsyncWriter'
|
132
|
+
_header: bytearray
|
133
|
+
_buffer: bytearray
|
134
|
+
_decompressor: Decompressor | None
|
135
|
+
|
136
|
+
def __init__(
|
137
|
+
self,
|
138
|
+
writer: 'AsyncWriter',
|
139
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
140
|
+
logger: Logger | LoggerAdapter | None = None,
|
141
|
+
) -> None:
|
142
|
+
self._writer = writer
|
143
|
+
self._buffer = bytearray()
|
144
|
+
self._decompressor = None
|
145
|
+
self._header = bytearray()
|
146
|
+
self.chunk_size = chunk_size
|
147
|
+
self.logger = logger or module_logger
|
148
|
+
|
149
|
+
async def write(self, data: bytes, /) -> None:
|
150
|
+
if not data:
|
151
|
+
return
|
152
|
+
await self._fill_buffer(data)
|
153
|
+
if len(self._buffer) >= self.chunk_size:
|
154
|
+
await self._writer.write(self._buffer)
|
155
|
+
self._buffer.clear()
|
156
|
+
|
157
|
+
async def flush(self) -> None:
|
158
|
+
if self._decompressor:
|
159
|
+
data = self._decompressor.flush()
|
160
|
+
self.logger.debug('Flushing decompressor: %d bytes', len(data))
|
161
|
+
self._buffer.extend(data)
|
162
|
+
if self._buffer:
|
163
|
+
await self._writer.write(self._buffer)
|
164
|
+
self._buffer.clear()
|
165
|
+
await self._writer.flush()
|
166
|
+
|
167
|
+
async def _fill_buffer(self, data: bytes) -> None:
|
168
|
+
self.logger.debug('Filling buffer with chunk of %d bytes', len(data))
|
169
|
+
if len(self._header) < FULL_HEADER_SIZE:
|
170
|
+
header_offset = FULL_HEADER_SIZE - len(self._header)
|
171
|
+
self._header.extend(data[:header_offset])
|
172
|
+
data = data[header_offset:]
|
173
|
+
if len(self._header) < FULL_HEADER_SIZE:
|
174
|
+
return
|
175
|
+
|
176
|
+
compression_type = CompressionT(self._header[:COMPRESSION_HEADER_SIZE])
|
177
|
+
self.logger.debug('Extracted compression type: %s', compression_type)
|
178
|
+
|
179
|
+
if compression_type == CompressionT.NONE:
|
180
|
+
self._decompressor = None
|
181
|
+
elif compression_type == CompressionT.ZLIB:
|
182
|
+
d = zlib.decompressobj()
|
183
|
+
self._decompressor = d
|
184
|
+
elif compression_type == CompressionT.ZSTD:
|
185
|
+
if not zstd:
|
186
|
+
raise RuntimeError('zstandard is not available')
|
187
|
+
self._decompressor = zstd.ZstdDecompressor().decompressobj()
|
188
|
+
|
189
|
+
if not data:
|
190
|
+
return
|
191
|
+
|
192
|
+
if not self._decompressor:
|
193
|
+
self._buffer.extend(data)
|
194
|
+
return
|
195
|
+
|
196
|
+
data = self._decompressor.decompress(data)
|
197
|
+
self.logger.debug('Got %d bytes from decompressor', len(data))
|
198
|
+
self._buffer.extend(data)
|
@@ -0,0 +1,37 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
|
3
|
+
from .package import CompressionT
|
4
|
+
|
5
|
+
__all__ = ('choose_compression',)
|
6
|
+
|
7
|
+
MAGIC_HEADER_SIZE = 4
|
8
|
+
MINIMAL_COMPRESSION_SIZE = 1024
|
9
|
+
|
10
|
+
|
11
|
+
# Magic header prefixes for various compression formats
|
12
|
+
MAGIC_HEADER_PREFIXES = [
|
13
|
+
bytes([0x1F, 0x8B]), # gzip
|
14
|
+
bytes([0x42, 0x5A, 0x68]), # bzip2
|
15
|
+
bytes([0x50, 0x4B, 0x03]), # zip
|
16
|
+
bytes([0x28, 0xB5, 0x2F, 0xFD]), # zstd
|
17
|
+
bytes([0x78, 0x01]), # default compression level
|
18
|
+
]
|
19
|
+
|
20
|
+
|
21
|
+
def is_file_already_compressed(file_path: Path) -> bool:
|
22
|
+
with open(file_path, 'rb') as file:
|
23
|
+
magic_header_buff = file.read(MAGIC_HEADER_SIZE)
|
24
|
+
|
25
|
+
return any(magic_header_buff.startswith(prefix) for prefix in MAGIC_HEADER_PREFIXES)
|
26
|
+
|
27
|
+
|
28
|
+
def choose_compression(
|
29
|
+
file_path: Path, preferred_compression: CompressionT = CompressionT.NONE
|
30
|
+
) -> CompressionT:
|
31
|
+
if file_path.stat().st_size < MINIMAL_COMPRESSION_SIZE:
|
32
|
+
return CompressionT.NONE
|
33
|
+
|
34
|
+
if is_file_already_compressed(file_path):
|
35
|
+
return CompressionT.NONE
|
36
|
+
|
37
|
+
return preferred_compression
|