omdev 0.0.0.dev289__py3-none-any.whl → 0.0.0.dev291__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.
omdev/scripts/ci.py CHANGED
@@ -71,6 +71,7 @@ import urllib.parse
71
71
  import urllib.request
72
72
  import uuid
73
73
  import weakref
74
+ import xml.etree.ElementTree as ET
74
75
 
75
76
 
76
77
  ########################################
@@ -103,6 +104,10 @@ TimeoutLike = ta.Union['Timeout', ta.Type['Timeout.Default'], ta.Iterable['Timeo
103
104
  # ../../omlish/sockets/addresses.py
104
105
  SocketAddress = ta.Any
105
106
 
107
+ # github/api/v2/api.py
108
+ GithubCacheServiceV2RequestT = ta.TypeVar('GithubCacheServiceV2RequestT')
109
+ GithubCacheServiceV2ResponseT = ta.TypeVar('GithubCacheServiceV2ResponseT')
110
+
106
111
  # ../../omlish/argparse/cli.py
107
112
  ArgparseCmdFn = ta.Callable[[], ta.Optional[int]] # ta.TypeAlias
108
113
 
@@ -418,6 +423,31 @@ class DockerPortRelay:
418
423
  ]
419
424
 
420
425
 
426
+ ########################################
427
+ # ../../../omlish/http/urllib.py
428
+
429
+
430
+ ##
431
+
432
+
433
+ class NonRaisingUrllibErrorProcessor(urllib.request.HTTPErrorProcessor):
434
+ """
435
+ https://stackoverflow.com/a/74844056
436
+
437
+ Usage:
438
+
439
+ opener = urllib.request.build_opener(NonRaisingUrllibErrorProcessor)
440
+ with opener.open(req) as resp:
441
+ ...
442
+ """
443
+
444
+ def http_response(self, request, response):
445
+ return response
446
+
447
+ def https_response(self, request, response):
448
+ return response
449
+
450
+
421
451
  ########################################
422
452
  # ../../../omlish/http/versions.py
423
453
 
@@ -1985,7 +2015,7 @@ def read_docker_tar_image_id(tar_file: str) -> str:
1985
2015
 
1986
2016
 
1987
2017
  ########################################
1988
- # ../github/api.py
2018
+ # ../github/api/v1/api.py
1989
2019
  """
1990
2020
  export FILE_SIZE=$(stat --format="%s" $FILE)
1991
2021
 
@@ -2028,6 +2058,11 @@ curl -s \
2028
2058
 
2029
2059
 
2030
2060
  class GithubCacheServiceV1:
2061
+ def __new__(cls, *args, **kwargs): # noqa
2062
+ raise TypeError
2063
+
2064
+ #
2065
+
2031
2066
  API_VERSION = '6.0-preview.1'
2032
2067
 
2033
2068
  @classmethod
@@ -2098,14 +2133,54 @@ class GithubCacheServiceV1:
2098
2133
  cache_size: ta.Optional[int]
2099
2134
 
2100
2135
 
2136
+ ########################################
2137
+ # ../github/api/v2/api.py
2138
+ """
2139
+ https://github.com/tonistiigi/go-actions-cache/blob/3e9a6642607fd6e4d5d4fdab7c91fe8bf4c36a25/cache_v2.go
2140
+
2141
+ ==
2142
+
2143
+ curl -s \
2144
+ -X POST \
2145
+ "${ACTIONS_RESULTS_URL}twirp/github.actions.results.api.v1.CacheService/CreateCacheEntry" \
2146
+ -H 'Content-Type: application/json' \
2147
+ -H "Authorization: Bearer $ACTIONS_RUNTIME_TOKEN" \
2148
+ -d '{"key": "foo", "version": "0000000000000000000000000000000000000000000000000000000000000001" }' \
2149
+ | jq .
2150
+
2151
+ curl -s \
2152
+ -X POST \
2153
+ "${ACTIONS_RESULTS_URL}twirp/github.actions.results.api.v1.CacheService/GetCacheEntryDownloadURL" \
2154
+ -H 'Content-Type: application/json' \
2155
+ -H "Authorization: Bearer $ACTIONS_RUNTIME_TOKEN" \
2156
+ -d '{"key": "foo", "restoreKeys": [], "version": "0000000000000000000000000000000000000000000000000000000000000001" }' \
2157
+ | jq .
2158
+
2159
+ """ # noqa
2160
+
2161
+
2162
+ ##
2163
+
2164
+
2101
2165
  class GithubCacheServiceV2:
2166
+ def __new__(cls, *args, **kwargs): # noqa
2167
+ raise TypeError
2168
+
2169
+ #
2170
+
2102
2171
  SERVICE_NAME = 'github.actions.results.api.v1.CacheService'
2103
2172
 
2173
+ @classmethod
2174
+ def get_service_url(cls, base_url: str) -> str:
2175
+ return f'{base_url.rstrip("/")}/twirp/{cls.SERVICE_NAME}'
2176
+
2177
+ #
2178
+
2104
2179
  @dc.dataclass(frozen=True)
2105
- class Method:
2180
+ class Method(ta.Generic[GithubCacheServiceV2RequestT, GithubCacheServiceV2ResponseT]):
2106
2181
  name: str
2107
- request: type
2108
- response: type
2182
+ request: ta.Type[GithubCacheServiceV2RequestT]
2183
+ response: ta.Type[GithubCacheServiceV2ResponseT]
2109
2184
 
2110
2185
  #
2111
2186
 
@@ -2124,6 +2199,8 @@ class GithubCacheServiceV2:
2124
2199
  repository_id: int
2125
2200
  scope: ta.Sequence['GithubCacheServiceV2.CacheScope']
2126
2201
 
2202
+ VERSION_LENGTH: int = 64
2203
+
2127
2204
  #
2128
2205
 
2129
2206
  @dc.dataclass(frozen=True)
@@ -2132,12 +2209,18 @@ class GithubCacheServiceV2:
2132
2209
  version: str
2133
2210
  metadata: ta.Optional['GithubCacheServiceV2.CacheMetadata'] = None
2134
2211
 
2212
+ def __post_init__(self) -> None:
2213
+ check.equal(len(self.version), GithubCacheServiceV2.VERSION_LENGTH)
2214
+
2135
2215
  @dc.dataclass(frozen=True)
2136
2216
  class CreateCacheEntryResponse:
2137
2217
  ok: bool
2138
2218
  signed_upload_url: str
2139
2219
 
2140
- CREATE_CACHE_ENTRY_METHOD = Method(
2220
+ CREATE_CACHE_ENTRY_METHOD: Method[
2221
+ CreateCacheEntryRequest,
2222
+ CreateCacheEntryResponse,
2223
+ ] = Method(
2141
2224
  'CreateCacheEntry',
2142
2225
  CreateCacheEntryRequest,
2143
2226
  CreateCacheEntryResponse,
@@ -2157,7 +2240,10 @@ class GithubCacheServiceV2:
2157
2240
  ok: bool
2158
2241
  entry_id: str
2159
2242
 
2160
- FINALIZE_CACHE_ENTRY_METHOD = Method(
2243
+ FINALIZE_CACHE_ENTRY_METHOD: Method[
2244
+ FinalizeCacheEntryUploadRequest,
2245
+ FinalizeCacheEntryUploadResponse,
2246
+ ] = Method(
2161
2247
  'FinalizeCacheEntryUpload',
2162
2248
  FinalizeCacheEntryUploadRequest,
2163
2249
  FinalizeCacheEntryUploadResponse,
@@ -2178,7 +2264,10 @@ class GithubCacheServiceV2:
2178
2264
  signed_download_url: str
2179
2265
  matched_key: str
2180
2266
 
2181
- GET_CACHE_ENTRY_DOWNLOAD_URL_METHOD = Method(
2267
+ GET_CACHE_ENTRY_DOWNLOAD_URL_METHOD: Method[
2268
+ GetCacheEntryDownloadUrlRequest,
2269
+ GetCacheEntryDownloadUrlResponse,
2270
+ ] = Method(
2182
2271
  'GetCacheEntryDownloadURL',
2183
2272
  GetCacheEntryDownloadUrlRequest,
2184
2273
  GetCacheEntryDownloadUrlResponse,
@@ -5978,13 +6067,14 @@ class FileCacheDataCache(DataCache):
5978
6067
 
5979
6068
 
5980
6069
  ########################################
5981
- # ../github/client.py
6070
+ # ../github/api/clients.py
5982
6071
 
5983
6072
 
5984
6073
  ##
5985
6074
 
5986
6075
 
5987
6076
  class GithubCacheClient(abc.ABC):
6077
+ @dc.dataclass(frozen=True)
5988
6078
  class Entry(abc.ABC): # noqa
5989
6079
  pass
5990
6080
 
@@ -6007,18 +6097,21 @@ class GithubCacheClient(abc.ABC):
6007
6097
  ##
6008
6098
 
6009
6099
 
6010
- class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6011
- BASE_URL_ENV_VAR = register_github_env_var('ACTIONS_CACHE_URL')
6100
+ class BaseGithubCacheClient(GithubCacheClient, abc.ABC):
6012
6101
  AUTH_TOKEN_ENV_VAR = register_github_env_var('ACTIONS_RUNTIME_TOKEN') # noqa
6013
6102
 
6014
6103
  KEY_SUFFIX_ENV_VAR = register_github_env_var('GITHUB_RUN_ID')
6015
6104
 
6105
+ DEFAULT_CONCURRENCY = 4
6106
+ DEFAULT_CHUNK_SIZE = 64 * 1024 * 1024
6107
+
6016
6108
  #
6017
6109
 
6018
6110
  def __init__(
6019
6111
  self,
6020
6112
  *,
6021
- base_url: ta.Optional[str] = None,
6113
+ service_url: str,
6114
+
6022
6115
  auth_token: ta.Optional[str] = None,
6023
6116
 
6024
6117
  key_prefix: ta.Optional[str] = None,
@@ -6027,14 +6120,15 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6027
6120
  cache_version: int = CI_CACHE_VERSION,
6028
6121
 
6029
6122
  loop: ta.Optional[asyncio.AbstractEventLoop] = None,
6123
+
6124
+ concurrency: int = DEFAULT_CONCURRENCY,
6125
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
6030
6126
  ) -> None:
6031
6127
  super().__init__()
6032
6128
 
6033
6129
  #
6034
6130
 
6035
- if base_url is None:
6036
- base_url = check.non_empty_str(self.BASE_URL_ENV_VAR())
6037
- self._service_url = GithubCacheServiceV1.get_service_url(base_url)
6131
+ self._service_url = check.non_empty_str(service_url)
6038
6132
 
6039
6133
  if auth_token is None:
6040
6134
  auth_token = self.AUTH_TOKEN_ENV_VAR()
@@ -6056,7 +6150,16 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6056
6150
 
6057
6151
  self._given_loop = loop
6058
6152
 
6059
- #
6153
+ #
6154
+
6155
+ check.arg(concurrency > 0)
6156
+ self._concurrency = concurrency
6157
+
6158
+ check.arg(chunk_size > 0)
6159
+ self._chunk_size = chunk_size
6160
+
6161
+ ##
6162
+ # misc
6060
6163
 
6061
6164
  def _get_loop(self) -> asyncio.AbstractEventLoop:
6062
6165
  if (loop := self._given_loop) is not None:
@@ -6065,21 +6168,25 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6065
6168
 
6066
6169
  #
6067
6170
 
6068
- def build_request_headers(
6171
+ def _load_json_bytes(self, b: ta.Optional[bytes]) -> ta.Optional[ta.Any]:
6172
+ if not b:
6173
+ return None
6174
+ return json.loads(b.decode('utf-8-sig'))
6175
+
6176
+ ##
6177
+ # requests
6178
+
6179
+ def _build_request_headers(
6069
6180
  self,
6070
6181
  headers: ta.Optional[ta.Mapping[str, str]] = None,
6071
6182
  *,
6183
+ no_auth: bool = False,
6072
6184
  content_type: ta.Optional[str] = None,
6073
6185
  json_content: bool = False,
6074
6186
  ) -> ta.Dict[str, str]:
6075
- dct = {
6076
- 'Accept': ';'.join([
6077
- 'application/json',
6078
- f'api-version={GithubCacheServiceV1.API_VERSION}',
6079
- ]),
6080
- }
6187
+ dct = {}
6081
6188
 
6082
- if (auth_token := self._auth_token):
6189
+ if not no_auth and (auth_token := self._auth_token):
6083
6190
  dct['Authorization'] = f'Bearer {auth_token}'
6084
6191
 
6085
6192
  if content_type is None and json_content:
@@ -6094,19 +6201,13 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6094
6201
 
6095
6202
  #
6096
6203
 
6097
- def load_json_bytes(self, b: ta.Optional[bytes]) -> ta.Optional[ta.Any]:
6098
- if not b:
6099
- return None
6100
- return json.loads(b.decode('utf-8-sig'))
6101
-
6102
- #
6103
-
6104
- async def send_url_request(
6204
+ async def _send_urllib_request(
6105
6205
  self,
6106
6206
  req: urllib.request.Request,
6107
6207
  ) -> ta.Tuple[http.client.HTTPResponse, ta.Optional[bytes]]:
6108
6208
  def run_sync():
6109
- with urllib.request.urlopen(req) as resp: # noqa
6209
+ opener = urllib.request.build_opener(NonRaisingUrllibErrorProcessor)
6210
+ with opener.open(req) as resp: # noqa
6110
6211
  body = resp.read()
6111
6212
  return (resp, body)
6112
6213
 
@@ -6122,18 +6223,32 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6122
6223
  def __str__(self) -> str:
6123
6224
  return repr(self)
6124
6225
 
6125
- async def send_service_request(
6226
+ async def _send_request(
6126
6227
  self,
6127
- path: str,
6128
6228
  *,
6229
+ url: ta.Optional[str] = None,
6230
+ path: ta.Optional[str] = None,
6231
+
6129
6232
  method: ta.Optional[str] = None,
6233
+
6130
6234
  headers: ta.Optional[ta.Mapping[str, str]] = None,
6235
+ no_auth: bool = False,
6131
6236
  content_type: ta.Optional[str] = None,
6237
+
6132
6238
  content: ta.Optional[bytes] = None,
6133
6239
  json_content: ta.Optional[ta.Any] = None,
6240
+
6134
6241
  success_status_codes: ta.Optional[ta.Container[int]] = None,
6242
+
6243
+ retry_status_codes: ta.Optional[ta.Container[int]] = None,
6244
+ num_retries: int = 0,
6245
+ retry_sleep: ta.Optional[float] = None,
6135
6246
  ) -> ta.Optional[ta.Any]:
6136
- url = f'{self._service_url}/{path}'
6247
+ if url is not None and path is not None:
6248
+ raise RuntimeError('Must not pass both url and path')
6249
+ elif path is not None:
6250
+ url = f'{self._service_url}/{path}'
6251
+ url = check.non_empty_str(url)
6137
6252
 
6138
6253
  if content is not None and json_content is not None:
6139
6254
  raise RuntimeError('Must not pass both content and json_content')
@@ -6146,33 +6261,52 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6146
6261
  if method is None:
6147
6262
  method = 'POST' if content is not None else 'GET'
6148
6263
 
6264
+ headers = self._build_request_headers(
6265
+ headers,
6266
+ no_auth=no_auth,
6267
+ content_type=content_type,
6268
+ json_content=header_json_content,
6269
+ )
6270
+
6149
6271
  #
6150
6272
 
6151
- req = urllib.request.Request( # noqa
6152
- url,
6153
- method=method,
6154
- headers=self.build_request_headers(
6155
- headers,
6156
- content_type=content_type,
6157
- json_content=header_json_content,
6158
- ),
6159
- data=content,
6160
- )
6273
+ for n in itertools.count():
6274
+ req = urllib.request.Request( # noqa
6275
+ url,
6276
+ method=method,
6277
+ headers=headers,
6278
+ data=content,
6279
+ )
6161
6280
 
6162
- resp, body = await self.send_url_request(req)
6281
+ resp, body = await self._send_urllib_request(req)
6163
6282
 
6164
- #
6283
+ #
6165
6284
 
6166
- if success_status_codes is not None:
6167
- is_success = resp.status in success_status_codes
6168
- else:
6169
- is_success = (200 <= resp.status <= 300)
6170
- if not is_success:
6171
- raise self.ServiceRequestError(resp.status, body)
6285
+ if success_status_codes is not None:
6286
+ is_success = resp.status in success_status_codes
6287
+ else:
6288
+ is_success = (200 <= resp.status < 300)
6289
+ if is_success:
6290
+ return self._load_json_bytes(body)
6291
+
6292
+ #
6172
6293
 
6173
- return self.load_json_bytes(body)
6294
+ log.debug(f'Request to url {url} got unsuccessful status code {resp.status}') # noqa
6174
6295
 
6175
- #
6296
+ if not (
6297
+ retry_status_codes is not None and
6298
+ resp.status in retry_status_codes and
6299
+ n < num_retries
6300
+ ):
6301
+ raise self.ServiceRequestError(resp.status, body)
6302
+
6303
+ if retry_sleep is not None:
6304
+ await asyncio.sleep(retry_sleep)
6305
+
6306
+ raise RuntimeError('Unreachable')
6307
+
6308
+ ##
6309
+ # keys
6176
6310
 
6177
6311
  KEY_PART_SEPARATOR = '---'
6178
6312
 
@@ -6183,73 +6317,8 @@ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
6183
6317
  ('' if partial_suffix else self._key_suffix),
6184
6318
  ])
6185
6319
 
6186
- #
6187
-
6188
- @dc.dataclass(frozen=True)
6189
- class Entry(GithubCacheClient.Entry):
6190
- artifact: GithubCacheServiceV1.ArtifactCacheEntry
6191
-
6192
- def get_entry_url(self, entry: GithubCacheClient.Entry) -> ta.Optional[str]:
6193
- entry1 = check.isinstance(entry, self.Entry)
6194
- return entry1.artifact.archive_location
6195
-
6196
- #
6197
-
6198
- def build_get_entry_url_path(self, *keys: str) -> str:
6199
- qp = dict(
6200
- keys=','.join(urllib.parse.quote_plus(k) for k in keys),
6201
- version=str(self._cache_version),
6202
- )
6203
-
6204
- return '?'.join([
6205
- 'cache',
6206
- '&'.join([
6207
- f'{k}={v}'
6208
- for k, v in qp.items()
6209
- ]),
6210
- ])
6211
-
6212
- GET_ENTRY_SUCCESS_STATUS_CODES = (200, 204)
6213
-
6214
-
6215
- ##
6216
-
6217
-
6218
- class GithubCacheServiceV1Client(GithubCacheServiceV1BaseClient):
6219
- DEFAULT_CONCURRENCY = 4
6220
-
6221
- DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024
6222
-
6223
- def __init__(
6224
- self,
6225
- *,
6226
- concurrency: int = DEFAULT_CONCURRENCY,
6227
- chunk_size: int = DEFAULT_CHUNK_SIZE,
6228
- **kwargs: ta.Any,
6229
- ) -> None:
6230
- super().__init__(**kwargs)
6231
-
6232
- check.arg(concurrency > 0)
6233
- self._concurrency = concurrency
6234
-
6235
- check.arg(chunk_size > 0)
6236
- self._chunk_size = chunk_size
6237
-
6238
- #
6239
-
6240
- async def get_entry(self, key: str) -> ta.Optional[GithubCacheServiceV1BaseClient.Entry]:
6241
- obj = await self.send_service_request(
6242
- self.build_get_entry_url_path(self.fix_key(key, partial_suffix=True)),
6243
- )
6244
- if obj is None:
6245
- return None
6246
-
6247
- return self.Entry(GithubCacheServiceV1.dataclass_from_json(
6248
- GithubCacheServiceV1.ArtifactCacheEntry,
6249
- obj,
6250
- ))
6251
-
6252
- #
6320
+ ##
6321
+ # downloading
6253
6322
 
6254
6323
  @dc.dataclass(frozen=True)
6255
6324
  class _DownloadChunk:
@@ -6267,7 +6336,7 @@ class GithubCacheServiceV1Client(GithubCacheServiceV1BaseClient):
6267
6336
  },
6268
6337
  )
6269
6338
 
6270
- _, buf_ = await self.send_url_request(req)
6339
+ _, buf_ = await self._send_urllib_request(req)
6271
6340
 
6272
6341
  buf = check.not_none(buf_)
6273
6342
  check.equal(len(buf), chunk.size)
@@ -6310,7 +6379,7 @@ class GithubCacheServiceV1Client(GithubCacheServiceV1BaseClient):
6310
6379
  #
6311
6380
  # status_code = check.isinstance(curl_res['response_code'], int)
6312
6381
  #
6313
- # if not (200 <= status_code <= 300):
6382
+ # if not (200 <= status_code < 300):
6314
6383
  # raise RuntimeError(f'Curl chunk download {chunk} failed: {curl_res}')
6315
6384
 
6316
6385
  async def _download_file_chunk(self, chunk: _DownloadChunk) -> None:
@@ -6322,11 +6391,17 @@ class GithubCacheServiceV1Client(GithubCacheServiceV1BaseClient):
6322
6391
  ):
6323
6392
  await self._download_file_chunk_urllib(chunk)
6324
6393
 
6325
- async def _download_file(self, entry: GithubCacheServiceV1BaseClient.Entry, out_file: str) -> None:
6326
- key = check.non_empty_str(entry.artifact.cache_key)
6327
- url = check.non_empty_str(entry.artifact.archive_location)
6394
+ async def _download_file_chunks(
6395
+ self,
6396
+ *,
6397
+ key: str,
6398
+ url: str,
6399
+ out_file: str,
6400
+ ) -> None:
6401
+ check.non_empty_str(key)
6402
+ check.non_empty_str(url)
6328
6403
 
6329
- head_resp, _ = await self.send_url_request(urllib.request.Request( # noqa
6404
+ head_resp, _ = await self._send_urllib_request(urllib.request.Request( # noqa
6330
6405
  url,
6331
6406
  method='HEAD',
6332
6407
  ))
@@ -6355,109 +6430,281 @@ class GithubCacheServiceV1Client(GithubCacheServiceV1BaseClient):
6355
6430
 
6356
6431
  await asyncio_wait_concurrent(download_tasks, self._concurrency)
6357
6432
 
6358
- async def download_file(self, entry: GithubCacheClient.Entry, out_file: str) -> None:
6359
- entry1 = check.isinstance(entry, self.Entry)
6360
- with log_timing_context(
6361
- 'Downloading github cache '
6362
- f'key {entry1.artifact.cache_key} '
6363
- f'version {entry1.artifact.cache_version} '
6364
- f'to {out_file}',
6365
- ):
6366
- await self._download_file(entry1, out_file)
6433
+ ##
6434
+ # uploading
6367
6435
 
6368
- #
6436
+ @dc.dataclass(frozen=True)
6437
+ class _UploadChunk:
6438
+ url: str
6439
+ key: str
6440
+ in_file: str
6441
+ offset: int
6442
+ size: int
6369
6443
 
6370
- async def _upload_file_chunk(
6371
- self,
6372
- key: str,
6373
- cache_id: int,
6374
- in_file: str,
6375
- offset: int,
6376
- size: int,
6377
- ) -> None:
6378
- with log_timing_context(
6379
- f'Uploading github cache {key} '
6380
- f'file {in_file} '
6381
- f'chunk {offset} - {offset + size}',
6382
- ):
6383
- with open(in_file, 'rb') as f: # noqa
6384
- f.seek(offset)
6385
- buf = f.read(size)
6444
+ UPLOAD_CHUNK_NUM_RETRIES = 10
6445
+ UPLOAD_CHUNK_RETRY_SLEEP = .5
6386
6446
 
6387
- check.equal(len(buf), size)
6447
+ async def _upload_file_chunk_(self, chunk: _UploadChunk) -> None:
6448
+ with open(chunk.in_file, 'rb') as f: # noqa
6449
+ f.seek(chunk.offset)
6450
+ buf = f.read(chunk.size)
6388
6451
 
6389
- await self.send_service_request(
6390
- f'caches/{cache_id}',
6391
- method='PATCH',
6392
- content_type='application/octet-stream',
6393
- headers={
6394
- 'Content-Range': f'bytes {offset}-{offset + size - 1}/*',
6395
- },
6396
- content=buf,
6397
- success_status_codes=[204],
6398
- )
6452
+ check.equal(len(buf), chunk.size)
6399
6453
 
6400
- async def _upload_file(self, key: str, in_file: str) -> None:
6401
- fixed_key = self.fix_key(key)
6454
+ await self._send_request(
6455
+ url=chunk.url,
6402
6456
 
6403
- check.state(os.path.isfile(in_file))
6457
+ method='PATCH',
6404
6458
 
6405
- file_size = os.stat(in_file).st_size
6459
+ headers={
6460
+ 'Content-Range': f'bytes {chunk.offset}-{chunk.offset + chunk.size - 1}/*',
6461
+ },
6462
+ no_auth=True,
6463
+ content_type='application/octet-stream',
6406
6464
 
6407
- #
6465
+ content=buf,
6408
6466
 
6409
- reserve_req = GithubCacheServiceV1.ReserveCacheRequest(
6410
- key=fixed_key,
6411
- cache_size=file_size,
6412
- version=str(self._cache_version),
6413
- )
6414
- reserve_resp_obj = await self.send_service_request(
6415
- 'caches',
6416
- json_content=GithubCacheServiceV1.dataclass_to_json(reserve_req),
6417
- success_status_codes=[201],
6418
- )
6419
- reserve_resp = GithubCacheServiceV1.dataclass_from_json( # noqa
6420
- GithubCacheServiceV1.ReserveCacheResponse,
6421
- reserve_resp_obj,
6467
+ success_status_codes=[204],
6468
+
6469
+ # retry_status_codes=[405],
6470
+ num_retries=self.UPLOAD_CHUNK_NUM_RETRIES,
6471
+ retry_sleep=self.UPLOAD_CHUNK_RETRY_SLEEP,
6422
6472
  )
6423
- cache_id = check.isinstance(reserve_resp.cache_id, int)
6424
6473
 
6425
- log.debug(f'Github cache file {os.path.basename(in_file)} got id {cache_id}') # noqa
6474
+ async def _upload_file_chunk(self, chunk: _UploadChunk) -> None:
6475
+ with log_timing_context(
6476
+ f'Uploading github cache {chunk.key} '
6477
+ f'file {chunk.in_file} '
6478
+ f'chunk {chunk.offset} - {chunk.offset + chunk.size}',
6479
+ ):
6480
+ await self._upload_file_chunk_(chunk)
6481
+
6482
+ def _generate_file_upload_chunks(
6483
+ self,
6484
+ *,
6485
+ in_file: str,
6486
+ url: str,
6487
+ key: str,
6488
+
6489
+ file_size: ta.Optional[int] = None,
6490
+ ) -> ta.List[_UploadChunk]:
6491
+ check.state(os.path.isfile(in_file))
6492
+
6493
+ if file_size is None:
6494
+ file_size = os.stat(in_file).st_size
6426
6495
 
6427
6496
  #
6428
6497
 
6429
- upload_tasks = []
6498
+ upload_chunks: ta.List[BaseGithubCacheClient._UploadChunk] = []
6430
6499
  chunk_size = self._chunk_size
6431
6500
  for i in range((file_size // chunk_size) + (1 if file_size % chunk_size else 0)):
6432
6501
  offset = i * chunk_size
6433
6502
  size = min(chunk_size, file_size - offset)
6434
- upload_tasks.append(self._upload_file_chunk(
6435
- fixed_key,
6436
- cache_id,
6437
- in_file,
6438
- offset,
6439
- size,
6503
+ upload_chunks.append(self._UploadChunk(
6504
+ url=url,
6505
+ key=key,
6506
+ in_file=in_file,
6507
+ offset=offset,
6508
+ size=size,
6440
6509
  ))
6441
6510
 
6511
+ return upload_chunks
6512
+
6513
+ async def _upload_file_chunks(
6514
+ self,
6515
+ *,
6516
+ in_file: str,
6517
+ url: str,
6518
+ key: str,
6519
+
6520
+ file_size: ta.Optional[int] = None,
6521
+ ) -> None:
6522
+ upload_tasks = []
6523
+ for chunk in self._generate_file_upload_chunks(
6524
+ in_file=in_file,
6525
+ url=url,
6526
+ key=key,
6527
+ file_size=file_size,
6528
+ ):
6529
+ upload_tasks.append(self._upload_file_chunk(chunk))
6530
+
6442
6531
  await asyncio_wait_concurrent(upload_tasks, self._concurrency)
6443
6532
 
6444
- #
6445
6533
 
6446
- commit_req = GithubCacheServiceV1.CommitCacheRequest(
6447
- size=file_size,
6448
- )
6449
- await self.send_service_request(
6450
- f'caches/{cache_id}',
6451
- json_content=GithubCacheServiceV1.dataclass_to_json(commit_req),
6452
- success_status_codes=[204],
6453
- )
6534
+ ########################################
6535
+ # ../github/api/v2/azure.py
6536
+ """
6537
+ TODO:
6538
+ - ominfra? no, circdep
6539
+ """
6454
6540
 
6455
- async def upload_file(self, key: str, in_file: str) -> None:
6456
- with log_timing_context(
6457
- f'Uploading github cache file {os.path.basename(in_file)} '
6458
- f'key {key}',
6459
- ):
6460
- await self._upload_file(key, in_file)
6541
+
6542
+ ##
6543
+
6544
+
6545
+ class AzureBlockBlobUploader:
6546
+ """
6547
+ https://learn.microsoft.com/en-us/rest/api/storageservices/put-block
6548
+ https://learn.microsoft.com/en-us/rest/api/storageservices/put-block-list
6549
+ """
6550
+
6551
+ DEFAULT_CONCURRENCY = 4
6552
+
6553
+ @dc.dataclass(frozen=True)
6554
+ class Request:
6555
+ method: str
6556
+ url: str
6557
+ headers: ta.Optional[ta.Dict[str, str]] = None
6558
+ body: ta.Optional[bytes] = None
6559
+
6560
+ @dc.dataclass(frozen=True)
6561
+ class Response:
6562
+ status: int
6563
+ headers: ta.Optional[ta.Mapping[str, str]] = None
6564
+ data: ta.Optional[bytes] = None
6565
+
6566
+ def get_header(self, name: str) -> ta.Optional[str]:
6567
+ for k, v in (self.headers or {}).items():
6568
+ if k.lower() == name.lower():
6569
+ return v
6570
+ return None
6571
+
6572
+ def __init__(
6573
+ self,
6574
+ blob_url_with_sas: str,
6575
+ make_request: ta.Callable[[Request], ta.Awaitable[Response]],
6576
+ *,
6577
+ api_version: str = '2020-10-02',
6578
+ concurrency: int = DEFAULT_CONCURRENCY,
6579
+ ) -> None:
6580
+ """
6581
+ blob_url_with_sas should be of the form:
6582
+ https://<account>.blob.core.windows.net/<container>/<blob>?<SAS-token>
6583
+ """
6584
+
6585
+ super().__init__()
6586
+
6587
+ self._make_request = make_request
6588
+ self._api_version = api_version
6589
+ check.arg(concurrency >= 1)
6590
+ self._concurrency = concurrency
6591
+
6592
+ parsed = urllib.parse.urlparse(blob_url_with_sas)
6593
+ self._base_url = f'{parsed.scheme}://{parsed.netloc}'
6594
+ parts = parsed.path.lstrip('/').split('/', 1)
6595
+ self._container = parts[0]
6596
+ self._blob_name = parts[1]
6597
+ self._sas = parsed.query
6598
+
6599
+ def _headers(self) -> ta.Dict[str, str]:
6600
+ """Standard headers for Azure Blob REST calls."""
6601
+
6602
+ now = datetime.datetime.now(datetime.UTC).strftime('%a, %d %b %Y %H:%M:%S GMT')
6603
+ return {
6604
+ 'x-ms-date': now,
6605
+ 'x-ms-version': self._api_version,
6606
+ }
6607
+
6608
+ @dc.dataclass(frozen=True)
6609
+ class FileChunk:
6610
+ in_file: str
6611
+ offset: int
6612
+ size: int
6613
+
6614
+ async def _upload_file_chunk_(
6615
+ self,
6616
+ block_id: str,
6617
+ chunk: FileChunk,
6618
+ ) -> None:
6619
+ with open(chunk.in_file, 'rb') as f: # noqa
6620
+ f.seek(chunk.offset)
6621
+ data = f.read(chunk.size)
6622
+
6623
+ check.equal(len(data), chunk.size)
6624
+
6625
+ params = {
6626
+ 'comp': 'block',
6627
+ 'blockid': block_id,
6628
+ }
6629
+ query = self._sas + '&' + urllib.parse.urlencode(params)
6630
+ url = f'{self._base_url}/{self._container}/{self._blob_name}?{query}'
6631
+
6632
+ log.debug(f'Uploading azure blob chunk {chunk} with block id {block_id}') # noqa
6633
+
6634
+ resp = await self._make_request(self.Request(
6635
+ 'PUT',
6636
+ url,
6637
+ headers=self._headers(),
6638
+ body=data,
6639
+ ))
6640
+ if resp.status not in (201, 202):
6641
+ raise RuntimeError(f'Put Block failed: {block_id=} {resp.status=}')
6642
+
6643
+ async def _upload_file_chunk(
6644
+ self,
6645
+ block_id: str,
6646
+ chunk: FileChunk,
6647
+ ) -> None:
6648
+ with log_timing_context(f'Uploading azure blob chunk {chunk} with block id {block_id}'):
6649
+ await self._upload_file_chunk_(
6650
+ block_id,
6651
+ chunk,
6652
+ )
6653
+
6654
+ async def upload_file(
6655
+ self,
6656
+ chunks: ta.List[FileChunk],
6657
+ ) -> ta.Dict[str, ta.Any]:
6658
+ block_ids = []
6659
+
6660
+ # 1) Stage each block
6661
+ upload_tasks = []
6662
+ for idx, chunk in enumerate(chunks):
6663
+ # Generate a predictable block ID (must be URL-safe base64)
6664
+ raw_id = f'{idx:08d}'.encode()
6665
+ block_id = base64.b64encode(raw_id).decode('utf-8')
6666
+ block_ids.append(block_id)
6667
+
6668
+ upload_tasks.append(self._upload_file_chunk(
6669
+ block_id,
6670
+ chunk,
6671
+ ))
6672
+
6673
+ await asyncio_wait_concurrent(upload_tasks, self._concurrency)
6674
+
6675
+ # 2) Commit block list
6676
+ root = ET.Element('BlockList')
6677
+ for bid in block_ids:
6678
+ elm = ET.SubElement(root, 'Latest')
6679
+ elm.text = bid
6680
+ body = ET.tostring(root, encoding='utf-8', method='xml')
6681
+
6682
+ params = {'comp': 'blocklist'}
6683
+ query = self._sas + '&' + urllib.parse.urlencode(params)
6684
+ url = f'{self._base_url}/{self._container}/{self._blob_name}?{query}'
6685
+
6686
+ log.debug(f'Putting azure blob chunk list block ids {block_ids}') # noqa
6687
+
6688
+ resp = await self._make_request(self.Request(
6689
+ 'PUT',
6690
+ url,
6691
+ headers={
6692
+ **self._headers(),
6693
+ 'Content-Type': 'application/xml',
6694
+ },
6695
+ body=body,
6696
+ ))
6697
+ if resp.status not in (200, 201):
6698
+ raise RuntimeError(f'Put Block List failed: {resp.status} {resp.data!r}')
6699
+
6700
+ ret = {
6701
+ 'status_code': resp.status,
6702
+ 'etag': resp.get_header('ETag'),
6703
+ }
6704
+
6705
+ log.debug(f'Uploaded azure blob chunk {ret}') # noqa
6706
+
6707
+ return ret
6461
6708
 
6462
6709
 
6463
6710
  ########################################
@@ -7708,119 +7955,351 @@ def subprocess_maybe_shell_wrap_exec(*cmd: str) -> ta.Tuple[str, ...]:
7708
7955
 
7709
7956
 
7710
7957
  ########################################
7711
- # ../github/cache.py
7958
+ # ../github/api/v1/client.py
7712
7959
 
7713
7960
 
7714
7961
  ##
7715
7962
 
7716
7963
 
7717
- class GithubCache(FileCache, DataCache):
7718
- @dc.dataclass(frozen=True)
7719
- class Config:
7720
- pass
7964
+ class GithubCacheServiceV1Client(BaseGithubCacheClient):
7965
+ BASE_URL_ENV_VAR = register_github_env_var('ACTIONS_CACHE_URL')
7721
7966
 
7722
7967
  def __init__(
7723
7968
  self,
7724
- config: Config = Config(),
7725
7969
  *,
7726
- client: ta.Optional[GithubCacheClient] = None,
7727
- version: ta.Optional[CacheVersion] = None,
7970
+ base_url: ta.Optional[str] = None,
7728
7971
 
7729
- local: DirectoryFileCache,
7972
+ **kwargs: ta.Any,
7730
7973
  ) -> None:
7974
+ if base_url is None:
7975
+ base_url = check.non_empty_str(self.BASE_URL_ENV_VAR())
7976
+ service_url = GithubCacheServiceV1.get_service_url(base_url)
7977
+
7731
7978
  super().__init__(
7732
- version=version,
7979
+ service_url=service_url,
7980
+ **kwargs,
7733
7981
  )
7734
7982
 
7735
- self._config = config
7983
+ #
7736
7984
 
7737
- if client is None:
7738
- client = GithubCacheServiceV1Client(
7739
- cache_version=self._version,
7740
- )
7741
- self._client: GithubCacheClient = client
7985
+ def _build_request_headers(
7986
+ self,
7987
+ headers: ta.Optional[ta.Mapping[str, str]] = None,
7988
+ **kwargs: ta.Any,
7989
+ ) -> ta.Dict[str, str]:
7990
+ return super()._build_request_headers(
7991
+ {
7992
+ 'Accept': ';'.join([
7993
+ 'application/json',
7994
+ f'api-version={GithubCacheServiceV1.API_VERSION}',
7995
+ ]),
7996
+ **(headers or {}),
7997
+ },
7998
+ **kwargs,
7999
+ )
7742
8000
 
7743
- self._local = local
8001
+ #
8002
+
8003
+ @dc.dataclass(frozen=True)
8004
+ class Entry(GithubCacheClient.Entry):
8005
+ artifact: GithubCacheServiceV1.ArtifactCacheEntry
8006
+
8007
+ def get_entry_url(self, entry: GithubCacheClient.Entry) -> ta.Optional[str]:
8008
+ entry1 = check.isinstance(entry, self.Entry)
8009
+ return entry1.artifact.archive_location
7744
8010
 
7745
8011
  #
7746
8012
 
7747
- async def get_file(self, key: str) -> ta.Optional[str]:
7748
- local_file = self._local.get_cache_file_path(key)
7749
- if os.path.exists(local_file):
7750
- return local_file
8013
+ def _build_get_entry_url_path(self, *keys: str) -> str:
8014
+ qp = dict(
8015
+ keys=','.join(urllib.parse.quote_plus(k) for k in keys),
8016
+ version=str(self._cache_version),
8017
+ )
7751
8018
 
7752
- if (entry := await self._client.get_entry(key)) is None:
8019
+ return '?'.join([
8020
+ 'cache',
8021
+ '&'.join([
8022
+ f'{k}={v}'
8023
+ for k, v in qp.items()
8024
+ ]),
8025
+ ])
8026
+
8027
+ GET_ENTRY_SUCCESS_STATUS_CODES = (200, 204)
8028
+
8029
+ #
8030
+
8031
+ async def get_entry(self, key: str) -> ta.Optional[GithubCacheClient.Entry]:
8032
+ obj = await self._send_request(
8033
+ path=self._build_get_entry_url_path(self.fix_key(key, partial_suffix=True)),
8034
+ )
8035
+ if obj is None:
7753
8036
  return None
7754
8037
 
7755
- tmp_file = self._local.format_incomplete_file(local_file)
7756
- with unlinking_if_exists(tmp_file):
7757
- await self._client.download_file(entry, tmp_file)
8038
+ return self.Entry(GithubCacheServiceV1.dataclass_from_json(
8039
+ GithubCacheServiceV1.ArtifactCacheEntry,
8040
+ obj,
8041
+ ))
7758
8042
 
7759
- os.replace(tmp_file, local_file)
8043
+ #
7760
8044
 
7761
- return local_file
8045
+ async def download_file(self, entry: GithubCacheClient.Entry, out_file: str) -> None:
8046
+ entry1 = check.isinstance(entry, self.Entry)
8047
+ with log_timing_context(
8048
+ 'Downloading github cache '
8049
+ f'key {entry1.artifact.cache_key} '
8050
+ f'version {entry1.artifact.cache_version} '
8051
+ f'to {out_file}',
8052
+ ):
8053
+ await self._download_file_chunks(
8054
+ key=check.non_empty_str(entry1.artifact.cache_key),
8055
+ url=check.non_empty_str(entry1.artifact.archive_location),
8056
+ out_file=out_file,
8057
+ )
7762
8058
 
7763
- async def put_file(
8059
+ #
8060
+
8061
+ async def _upload_file(self, key: str, in_file: str) -> None:
8062
+ fixed_key = self.fix_key(key)
8063
+
8064
+ check.state(os.path.isfile(in_file))
8065
+ file_size = os.stat(in_file).st_size
8066
+
8067
+ #
8068
+
8069
+ reserve_req = GithubCacheServiceV1.ReserveCacheRequest(
8070
+ key=fixed_key,
8071
+ cache_size=file_size,
8072
+ version=str(self._cache_version),
8073
+ )
8074
+ reserve_resp_obj = await self._send_request(
8075
+ path='caches',
8076
+ json_content=GithubCacheServiceV1.dataclass_to_json(reserve_req),
8077
+ success_status_codes=[201],
8078
+ )
8079
+ reserve_resp = GithubCacheServiceV1.dataclass_from_json( # noqa
8080
+ GithubCacheServiceV1.ReserveCacheResponse,
8081
+ reserve_resp_obj,
8082
+ )
8083
+ cache_id = check.isinstance(reserve_resp.cache_id, int)
8084
+
8085
+ log.debug(f'Github cache file {os.path.basename(in_file)} got id {cache_id}') # noqa
8086
+
8087
+ #
8088
+
8089
+ url = f'{self._service_url}/caches/{cache_id}'
8090
+
8091
+ await self._upload_file_chunks(
8092
+ in_file=in_file,
8093
+ url=url,
8094
+ key=fixed_key,
8095
+ file_size=file_size,
8096
+ )
8097
+
8098
+ #
8099
+
8100
+ commit_req = GithubCacheServiceV1.CommitCacheRequest(
8101
+ size=file_size,
8102
+ )
8103
+ await self._send_request(
8104
+ path=f'caches/{cache_id}',
8105
+ json_content=GithubCacheServiceV1.dataclass_to_json(commit_req),
8106
+ success_status_codes=[204],
8107
+ )
8108
+
8109
+ async def upload_file(self, key: str, in_file: str) -> None:
8110
+ with log_timing_context(
8111
+ f'Uploading github cache file {os.path.basename(in_file)} '
8112
+ f'key {key}',
8113
+ ):
8114
+ await self._upload_file(key, in_file)
8115
+
8116
+
8117
+ ########################################
8118
+ # ../github/api/v2/client.py
8119
+
8120
+
8121
+ ##
8122
+
8123
+
8124
+ class GithubCacheServiceV2Client(BaseGithubCacheClient):
8125
+ BASE_URL_ENV_VAR = register_github_env_var('ACTIONS_RESULTS_URL')
8126
+
8127
+ def __init__(
7764
8128
  self,
7765
- key: str,
7766
- file_path: str,
7767
8129
  *,
7768
- steal: bool = False,
7769
- ) -> str:
7770
- cache_file_path = await self._local.put_file(
7771
- key,
7772
- file_path,
7773
- steal=steal,
8130
+ base_url: ta.Optional[str] = None,
8131
+
8132
+ **kwargs: ta.Any,
8133
+ ) -> None:
8134
+ if base_url is None:
8135
+ base_url = check.non_empty_str(self.BASE_URL_ENV_VAR())
8136
+ service_url = GithubCacheServiceV2.get_service_url(base_url)
8137
+
8138
+ super().__init__(
8139
+ service_url=service_url,
8140
+ **kwargs,
7774
8141
  )
7775
8142
 
7776
- await self._client.upload_file(key, cache_file_path)
8143
+ #
7777
8144
 
7778
- return cache_file_path
8145
+ async def _send_method_request(
8146
+ self,
8147
+ method: GithubCacheServiceV2.Method[
8148
+ GithubCacheServiceV2RequestT,
8149
+ GithubCacheServiceV2ResponseT,
8150
+ ],
8151
+ request: GithubCacheServiceV2RequestT,
8152
+ **kwargs: ta.Any,
8153
+ ) -> ta.Optional[GithubCacheServiceV2ResponseT]:
8154
+ obj = await self._send_request(
8155
+ path=method.name,
8156
+ json_content=dc.asdict(request), # type: ignore[call-overload]
8157
+ **kwargs,
8158
+ )
8159
+
8160
+ if obj is None:
8161
+ return None
8162
+ return method.response(**obj)
7779
8163
 
7780
8164
  #
7781
8165
 
7782
- async def get_data(self, key: str) -> ta.Optional[DataCache.Data]:
7783
- local_file = self._local.get_cache_file_path(key)
7784
- if os.path.exists(local_file):
7785
- return DataCache.FileData(local_file)
8166
+ @dc.dataclass(frozen=True)
8167
+ class Entry(GithubCacheClient.Entry):
8168
+ request: GithubCacheServiceV2.GetCacheEntryDownloadUrlRequest
8169
+ response: GithubCacheServiceV2.GetCacheEntryDownloadUrlResponse
7786
8170
 
7787
- if (entry := await self._client.get_entry(key)) is None:
8171
+ def __post_init__(self) -> None:
8172
+ check.state(self.response.ok)
8173
+ check.non_empty_str(self.response.signed_download_url)
8174
+
8175
+ def get_entry_url(self, entry: GithubCacheClient.Entry) -> ta.Optional[str]:
8176
+ entry2 = check.isinstance(entry, self.Entry)
8177
+ return check.non_empty_str(entry2.response.signed_download_url)
8178
+
8179
+ #
8180
+
8181
+ async def get_entry(self, key: str) -> ta.Optional[GithubCacheClient.Entry]:
8182
+ version = str(self._cache_version).zfill(GithubCacheServiceV2.VERSION_LENGTH)
8183
+
8184
+ req = GithubCacheServiceV2.GetCacheEntryDownloadUrlRequest(
8185
+ key=self.fix_key(key),
8186
+ restore_keys=[self.fix_key(key, partial_suffix=True)],
8187
+ version=version,
8188
+ )
8189
+
8190
+ resp = await self._send_method_request(
8191
+ GithubCacheServiceV2.GET_CACHE_ENTRY_DOWNLOAD_URL_METHOD,
8192
+ req,
8193
+ )
8194
+ if resp is None or not resp.ok:
7788
8195
  return None
7789
8196
 
7790
- return DataCache.UrlData(check.non_empty_str(self._client.get_entry_url(entry)))
8197
+ return self.Entry(
8198
+ request=req,
8199
+ response=resp,
8200
+ )
7791
8201
 
7792
- async def put_data(self, key: str, data: DataCache.Data) -> None:
7793
- await FileCacheDataCache(self).put_data(key, data)
8202
+ #
7794
8203
 
8204
+ async def download_file(self, entry: GithubCacheClient.Entry, out_file: str) -> None:
8205
+ entry2 = check.isinstance(entry, self.Entry)
8206
+ with log_timing_context(
8207
+ 'Downloading github cache '
8208
+ f'key {entry2.response.matched_key} '
8209
+ f'version {entry2.request.version} '
8210
+ f'to {out_file}',
8211
+ ):
8212
+ await self._download_file_chunks(
8213
+ key=check.non_empty_str(entry2.response.matched_key),
8214
+ url=check.non_empty_str(entry2.response.signed_download_url),
8215
+ out_file=out_file,
8216
+ )
7795
8217
 
7796
- ########################################
7797
- # ../github/cli.py
7798
- """
7799
- See:
7800
- - https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28
7801
- """
8218
+ #
7802
8219
 
8220
+ async def _upload_file(self, key: str, in_file: str) -> None:
8221
+ fixed_key = self.fix_key(key)
7803
8222
 
7804
- class GithubCli(ArgparseCli):
7805
- @argparse_cmd()
7806
- def list_referenced_env_vars(self) -> None:
7807
- print('\n'.join(sorted(ev.k for ev in GITHUB_ENV_VARS)))
8223
+ check.state(os.path.isfile(in_file))
8224
+ file_size = os.stat(in_file).st_size
7808
8225
 
7809
- @argparse_cmd(
7810
- argparse_arg('key'),
7811
- )
7812
- async def get_cache_entry(self) -> None:
7813
- client = GithubCacheServiceV1Client()
7814
- entry = await client.get_entry(self.args.key)
7815
- if entry is None:
7816
- return
7817
- print(json_dumps_pretty(dc.asdict(entry))) # noqa
8226
+ #
7818
8227
 
7819
- @argparse_cmd(
7820
- argparse_arg('repository-id'),
7821
- )
7822
- def list_cache_entries(self) -> None:
7823
- raise NotImplementedError
8228
+ version = str(self._cache_version).zfill(GithubCacheServiceV2.VERSION_LENGTH)
8229
+
8230
+ reserve_resp = check.not_none(await self._send_method_request(
8231
+ GithubCacheServiceV2.CREATE_CACHE_ENTRY_METHOD, # type: ignore[arg-type]
8232
+ GithubCacheServiceV2.CreateCacheEntryRequest(
8233
+ key=fixed_key,
8234
+ version=version,
8235
+ ),
8236
+ ))
8237
+ check.state(reserve_resp.ok)
8238
+
8239
+ log.debug(f'Github cache file {os.path.basename(in_file)} upload reserved for file size {file_size}') # noqa
8240
+
8241
+ #
8242
+
8243
+ upload_chunks = self._generate_file_upload_chunks(
8244
+ in_file=in_file,
8245
+ url=reserve_resp.signed_upload_url,
8246
+ key=fixed_key,
8247
+ file_size=file_size,
8248
+ )
8249
+
8250
+ az_chunks = [
8251
+ AzureBlockBlobUploader.FileChunk(
8252
+ in_file=in_file,
8253
+ offset=c.offset,
8254
+ size=c.size,
8255
+ )
8256
+ for c in upload_chunks
8257
+ ]
8258
+
8259
+ async def az_make_request(req: AzureBlockBlobUploader.Request) -> AzureBlockBlobUploader.Response:
8260
+ u_req = urllib.request.Request( # noqa
8261
+ req.url,
8262
+ method=req.method,
8263
+ headers=req.headers or {},
8264
+ data=req.body,
8265
+ )
8266
+
8267
+ u_resp, u_body = await self._send_urllib_request(u_req)
8268
+
8269
+ return AzureBlockBlobUploader.Response(
8270
+ status=u_resp.status,
8271
+ headers=dict(u_resp.headers),
8272
+ data=u_body,
8273
+ )
8274
+
8275
+ az_uploader = AzureBlockBlobUploader(
8276
+ reserve_resp.signed_upload_url,
8277
+ az_make_request,
8278
+ concurrency=self._concurrency,
8279
+ )
8280
+
8281
+ await az_uploader.upload_file(az_chunks)
8282
+
8283
+ #
8284
+
8285
+ commit_resp = check.not_none(await self._send_method_request(
8286
+ GithubCacheServiceV2.FINALIZE_CACHE_ENTRY_METHOD, # type: ignore[arg-type]
8287
+ GithubCacheServiceV2.FinalizeCacheEntryUploadRequest(
8288
+ key=fixed_key,
8289
+ size_bytes=file_size,
8290
+ version=version,
8291
+ ),
8292
+ ))
8293
+ check.state(commit_resp.ok)
8294
+
8295
+ log.debug(f'Github cache file {os.path.basename(in_file)} upload complete, entry id {commit_resp.entry_id}') # noqa
8296
+
8297
+ async def upload_file(self, key: str, in_file: str) -> None:
8298
+ with log_timing_context(
8299
+ f'Uploading github cache file {os.path.basename(in_file)} '
8300
+ f'key {key}',
8301
+ ):
8302
+ await self._upload_file(key, in_file)
7824
8303
 
7825
8304
 
7826
8305
  ########################################
@@ -9619,20 +10098,129 @@ async def build_cache_served_docker_image_data_server_routes(
9619
10098
 
9620
10099
 
9621
10100
  ########################################
9622
- # ../github/inject.py
10101
+ # ../github/cache.py
9623
10102
 
9624
10103
 
9625
10104
  ##
9626
10105
 
9627
10106
 
9628
- def bind_github() -> InjectorBindings:
9629
- lst: ta.List[InjectorBindingOrBindings] = [
9630
- inj.bind(GithubCache, singleton=True),
9631
- inj.bind(DataCache, to_key=GithubCache),
9632
- inj.bind(FileCache, to_key=GithubCache),
9633
- ]
10107
+ class GithubCache(FileCache, DataCache):
10108
+ @dc.dataclass(frozen=True)
10109
+ class Config:
10110
+ pass
9634
10111
 
9635
- return inj.as_bindings(*lst)
10112
+ DEFAULT_CLIENT_VERSION: ta.ClassVar[int] = 2
10113
+
10114
+ DEFAULT_CLIENTS_BY_VERSION: ta.ClassVar[ta.Mapping[int, ta.Callable[..., GithubCacheClient]]] = {
10115
+ 1: GithubCacheServiceV1Client,
10116
+ 2: GithubCacheServiceV2Client,
10117
+ }
10118
+
10119
+ def __init__(
10120
+ self,
10121
+ config: Config = Config(),
10122
+ *,
10123
+ client: ta.Optional[GithubCacheClient] = None,
10124
+ default_client_version: ta.Optional[int] = None,
10125
+
10126
+ version: ta.Optional[CacheVersion] = None,
10127
+
10128
+ local: DirectoryFileCache,
10129
+ ) -> None:
10130
+ super().__init__(
10131
+ version=version,
10132
+ )
10133
+
10134
+ self._config = config
10135
+
10136
+ if client is None:
10137
+ client_cls = self.DEFAULT_CLIENTS_BY_VERSION[default_client_version or self.DEFAULT_CLIENT_VERSION]
10138
+ client = client_cls(
10139
+ cache_version=self._version,
10140
+ )
10141
+ self._client: GithubCacheClient = client
10142
+
10143
+ self._local = local
10144
+
10145
+ #
10146
+
10147
+ async def get_file(self, key: str) -> ta.Optional[str]:
10148
+ local_file = self._local.get_cache_file_path(key)
10149
+ if os.path.exists(local_file):
10150
+ return local_file
10151
+
10152
+ if (entry := await self._client.get_entry(key)) is None:
10153
+ return None
10154
+
10155
+ tmp_file = self._local.format_incomplete_file(local_file)
10156
+ with unlinking_if_exists(tmp_file):
10157
+ await self._client.download_file(entry, tmp_file)
10158
+
10159
+ os.replace(tmp_file, local_file)
10160
+
10161
+ return local_file
10162
+
10163
+ async def put_file(
10164
+ self,
10165
+ key: str,
10166
+ file_path: str,
10167
+ *,
10168
+ steal: bool = False,
10169
+ ) -> str:
10170
+ cache_file_path = await self._local.put_file(
10171
+ key,
10172
+ file_path,
10173
+ steal=steal,
10174
+ )
10175
+
10176
+ await self._client.upload_file(key, cache_file_path)
10177
+
10178
+ return cache_file_path
10179
+
10180
+ #
10181
+
10182
+ async def get_data(self, key: str) -> ta.Optional[DataCache.Data]:
10183
+ local_file = self._local.get_cache_file_path(key)
10184
+ if os.path.exists(local_file):
10185
+ return DataCache.FileData(local_file)
10186
+
10187
+ if (entry := await self._client.get_entry(key)) is None:
10188
+ return None
10189
+
10190
+ return DataCache.UrlData(check.non_empty_str(self._client.get_entry_url(entry)))
10191
+
10192
+ async def put_data(self, key: str, data: DataCache.Data) -> None:
10193
+ await FileCacheDataCache(self).put_data(key, data)
10194
+
10195
+
10196
+ ########################################
10197
+ # ../github/cli.py
10198
+ """
10199
+ See:
10200
+ - https://docs.github.com/en/rest/actions/cache?apiVersion=2022-11-28
10201
+ """
10202
+
10203
+
10204
+ class GithubCli(ArgparseCli):
10205
+ @argparse_cmd()
10206
+ def list_referenced_env_vars(self) -> None:
10207
+ print('\n'.join(sorted(ev.k for ev in GITHUB_ENV_VARS)))
10208
+
10209
+ @argparse_cmd(
10210
+ argparse_arg('key'),
10211
+ )
10212
+ async def get_cache_entry(self) -> None:
10213
+ client = GithubCacheServiceV1Client()
10214
+ entry = await client.get_entry(self.args.key)
10215
+ if entry is None:
10216
+ return
10217
+ print(json_dumps_pretty(dc.asdict(entry))) # noqa
10218
+
10219
+ @argparse_cmd(
10220
+ argparse_arg('repository-id'),
10221
+ )
10222
+ def list_cache_entries(self) -> None:
10223
+ raise NotImplementedError
9636
10224
 
9637
10225
 
9638
10226
  ########################################
@@ -10325,6 +10913,23 @@ subprocesses = Subprocesses()
10325
10913
  SubprocessRun._DEFAULT_SUBPROCESSES = subprocesses # noqa
10326
10914
 
10327
10915
 
10916
+ ########################################
10917
+ # ../github/inject.py
10918
+
10919
+
10920
+ ##
10921
+
10922
+
10923
+ def bind_github() -> InjectorBindings:
10924
+ lst: ta.List[InjectorBindingOrBindings] = [
10925
+ inj.bind(GithubCache, singleton=True),
10926
+ inj.bind(DataCache, to_key=GithubCache),
10927
+ inj.bind(FileCache, to_key=GithubCache),
10928
+ ]
10929
+
10930
+ return inj.as_bindings(*lst)
10931
+
10932
+
10328
10933
  ########################################
10329
10934
  # ../requirements.py
10330
10935
  """