omdev 0.0.0.dev212__py3-none-any.whl → 0.0.0.dev214__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,492 @@
1
+ # ruff: noqa: TC003 UP006 UP007
2
+ import abc
3
+ import asyncio
4
+ import dataclasses as dc
5
+ import http.client
6
+ import json
7
+ import os
8
+ import typing as ta
9
+ import urllib.parse
10
+ import urllib.request
11
+
12
+ from omlish.asyncs.asyncio.asyncio import asyncio_wait_concurrent
13
+ from omlish.lite.check import check
14
+ from omlish.lite.json import json_dumps_compact
15
+ from omlish.lite.logs import log
16
+
17
+ from ..consts import CI_CACHE_VERSION
18
+ from ..utils import log_timing_context
19
+ from .api import GithubCacheServiceV1
20
+ from .env import register_github_env_var
21
+
22
+
23
+ ##
24
+
25
+
26
+ class GithubCacheClient(abc.ABC):
27
+ class Entry(abc.ABC): # noqa
28
+ pass
29
+
30
+ @abc.abstractmethod
31
+ def get_entry(self, key: str) -> ta.Awaitable[ta.Optional[Entry]]:
32
+ raise NotImplementedError
33
+
34
+ @abc.abstractmethod
35
+ def download_file(self, entry: Entry, out_file: str) -> ta.Awaitable[None]:
36
+ raise NotImplementedError
37
+
38
+ @abc.abstractmethod
39
+ def upload_file(self, key: str, in_file: str) -> ta.Awaitable[None]:
40
+ raise NotImplementedError
41
+
42
+
43
+ ##
44
+
45
+
46
+ class GithubCacheServiceV1BaseClient(GithubCacheClient, abc.ABC):
47
+ BASE_URL_ENV_VAR = register_github_env_var('ACTIONS_CACHE_URL')
48
+ AUTH_TOKEN_ENV_VAR = register_github_env_var('ACTIONS_RUNTIME_TOKEN') # noqa
49
+
50
+ KEY_SUFFIX_ENV_VAR = register_github_env_var('GITHUB_RUN_ID')
51
+
52
+ #
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ base_url: ta.Optional[str] = None,
58
+ auth_token: ta.Optional[str] = None,
59
+
60
+ key_prefix: ta.Optional[str] = None,
61
+ key_suffix: ta.Optional[str] = None,
62
+
63
+ cache_version: int = CI_CACHE_VERSION,
64
+
65
+ loop: ta.Optional[asyncio.AbstractEventLoop] = None,
66
+ ) -> None:
67
+ super().__init__()
68
+
69
+ #
70
+
71
+ if base_url is None:
72
+ base_url = check.non_empty_str(self.BASE_URL_ENV_VAR())
73
+ self._service_url = GithubCacheServiceV1.get_service_url(base_url)
74
+
75
+ if auth_token is None:
76
+ auth_token = self.AUTH_TOKEN_ENV_VAR()
77
+ self._auth_token = auth_token
78
+
79
+ #
80
+
81
+ self._key_prefix = key_prefix
82
+
83
+ if key_suffix is None:
84
+ key_suffix = self.KEY_SUFFIX_ENV_VAR()
85
+ self._key_suffix = check.non_empty_str(key_suffix)
86
+
87
+ #
88
+
89
+ self._cache_version = check.isinstance(cache_version, int)
90
+
91
+ #
92
+
93
+ self._given_loop = loop
94
+
95
+ #
96
+
97
+ def _get_loop(self) -> asyncio.AbstractEventLoop:
98
+ if (loop := self._given_loop) is not None:
99
+ return loop
100
+ return asyncio.get_event_loop()
101
+
102
+ #
103
+
104
+ def build_request_headers(
105
+ self,
106
+ headers: ta.Optional[ta.Mapping[str, str]] = None,
107
+ *,
108
+ content_type: ta.Optional[str] = None,
109
+ json_content: bool = False,
110
+ ) -> ta.Dict[str, str]:
111
+ dct = {
112
+ 'Accept': ';'.join([
113
+ 'application/json',
114
+ f'api-version={GithubCacheServiceV1.API_VERSION}',
115
+ ]),
116
+ }
117
+
118
+ if (auth_token := self._auth_token):
119
+ dct['Authorization'] = f'Bearer {auth_token}'
120
+
121
+ if content_type is None and json_content:
122
+ content_type = 'application/json'
123
+ if content_type is not None:
124
+ dct['Content-Type'] = content_type
125
+
126
+ if headers:
127
+ dct.update(headers)
128
+
129
+ return dct
130
+
131
+ #
132
+
133
+ def load_json_bytes(self, b: ta.Optional[bytes]) -> ta.Optional[ta.Any]:
134
+ if not b:
135
+ return None
136
+ return json.loads(b.decode('utf-8-sig'))
137
+
138
+ #
139
+
140
+ async def send_url_request(
141
+ self,
142
+ req: urllib.request.Request,
143
+ ) -> ta.Tuple[http.client.HTTPResponse, ta.Optional[bytes]]:
144
+ def run_sync():
145
+ with urllib.request.urlopen(req) as resp: # noqa
146
+ body = resp.read()
147
+ return (resp, body)
148
+
149
+ return await self._get_loop().run_in_executor(None, run_sync) # noqa
150
+
151
+ #
152
+
153
+ @dc.dataclass()
154
+ class ServiceRequestError(RuntimeError):
155
+ status_code: int
156
+ body: ta.Optional[bytes]
157
+
158
+ def __str__(self) -> str:
159
+ return repr(self)
160
+
161
+ async def send_service_request(
162
+ self,
163
+ path: str,
164
+ *,
165
+ method: ta.Optional[str] = None,
166
+ headers: ta.Optional[ta.Mapping[str, str]] = None,
167
+ content_type: ta.Optional[str] = None,
168
+ content: ta.Optional[bytes] = None,
169
+ json_content: ta.Optional[ta.Any] = None,
170
+ success_status_codes: ta.Optional[ta.Container[int]] = None,
171
+ ) -> ta.Optional[ta.Any]:
172
+ url = f'{self._service_url}/{path}'
173
+
174
+ if content is not None and json_content is not None:
175
+ raise RuntimeError('Must not pass both content and json_content')
176
+ elif json_content is not None:
177
+ content = json_dumps_compact(json_content).encode('utf-8')
178
+ header_json_content = True
179
+ else:
180
+ header_json_content = False
181
+
182
+ if method is None:
183
+ method = 'POST' if content is not None else 'GET'
184
+
185
+ #
186
+
187
+ req = urllib.request.Request( # noqa
188
+ url,
189
+ method=method,
190
+ headers=self.build_request_headers(
191
+ headers,
192
+ content_type=content_type,
193
+ json_content=header_json_content,
194
+ ),
195
+ data=content,
196
+ )
197
+
198
+ resp, body = await self.send_url_request(req)
199
+
200
+ #
201
+
202
+ if success_status_codes is not None:
203
+ is_success = resp.status in success_status_codes
204
+ else:
205
+ is_success = (200 <= resp.status <= 300)
206
+ if not is_success:
207
+ raise self.ServiceRequestError(resp.status, body)
208
+
209
+ return self.load_json_bytes(body)
210
+
211
+ #
212
+
213
+ KEY_PART_SEPARATOR = '--'
214
+
215
+ def fix_key(self, s: str, partial_suffix: bool = False) -> str:
216
+ return self.KEY_PART_SEPARATOR.join([
217
+ *([self._key_prefix] if self._key_prefix else []),
218
+ s,
219
+ ('' if partial_suffix else self._key_suffix),
220
+ ])
221
+
222
+ #
223
+
224
+ @dc.dataclass(frozen=True)
225
+ class Entry(GithubCacheClient.Entry):
226
+ artifact: GithubCacheServiceV1.ArtifactCacheEntry
227
+
228
+ #
229
+
230
+ def build_get_entry_url_path(self, *keys: str) -> str:
231
+ qp = dict(
232
+ keys=','.join(urllib.parse.quote_plus(k) for k in keys),
233
+ version=str(self._cache_version),
234
+ )
235
+
236
+ return '?'.join([
237
+ 'cache',
238
+ '&'.join([
239
+ f'{k}={v}'
240
+ for k, v in qp.items()
241
+ ]),
242
+ ])
243
+
244
+ GET_ENTRY_SUCCESS_STATUS_CODES = (200, 204)
245
+
246
+
247
+ ##
248
+
249
+
250
+ class GithubCacheServiceV1Client(GithubCacheServiceV1BaseClient):
251
+ DEFAULT_CONCURRENCY = 4
252
+
253
+ DEFAULT_CHUNK_SIZE = 32 * 1024 * 1024
254
+
255
+ def __init__(
256
+ self,
257
+ *,
258
+ concurrency: int = DEFAULT_CONCURRENCY,
259
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
260
+ **kwargs: ta.Any,
261
+ ) -> None:
262
+ super().__init__(**kwargs)
263
+
264
+ check.arg(concurrency > 0)
265
+ self._concurrency = concurrency
266
+
267
+ check.arg(chunk_size > 0)
268
+ self._chunk_size = chunk_size
269
+
270
+ #
271
+
272
+ async def get_entry(self, key: str) -> ta.Optional[GithubCacheServiceV1BaseClient.Entry]:
273
+ obj = await self.send_service_request(
274
+ self.build_get_entry_url_path(self.fix_key(key, partial_suffix=True)),
275
+ )
276
+ if obj is None:
277
+ return None
278
+
279
+ return self.Entry(GithubCacheServiceV1.dataclass_from_json(
280
+ GithubCacheServiceV1.ArtifactCacheEntry,
281
+ obj,
282
+ ))
283
+
284
+ #
285
+
286
+ @dc.dataclass(frozen=True)
287
+ class _DownloadChunk:
288
+ key: str
289
+ url: str
290
+ out_file: str
291
+ offset: int
292
+ size: int
293
+
294
+ async def _download_file_chunk_urllib(self, chunk: _DownloadChunk) -> None:
295
+ req = urllib.request.Request( # noqa
296
+ chunk.url,
297
+ headers={
298
+ 'Range': f'bytes={chunk.offset}-{chunk.offset + chunk.size - 1}',
299
+ },
300
+ )
301
+
302
+ _, buf_ = await self.send_url_request(req)
303
+
304
+ buf = check.not_none(buf_)
305
+ check.equal(len(buf), chunk.size)
306
+
307
+ #
308
+
309
+ def write_sync():
310
+ with open(chunk.out_file, 'r+b') as f: # noqa
311
+ f.seek(chunk.offset, os.SEEK_SET)
312
+ f.write(buf)
313
+
314
+ await self._get_loop().run_in_executor(None, write_sync) # noqa
315
+
316
+ # async def _download_file_chunk_curl(self, chunk: _DownloadChunk) -> None:
317
+ # async with contextlib.AsyncExitStack() as es:
318
+ # f = open(chunk.out_file, 'r+b')
319
+ # f.seek(chunk.offset, os.SEEK_SET)
320
+ #
321
+ # tmp_file = es.enter_context(temp_file_context()) # noqa
322
+ #
323
+ # proc = await es.enter_async_context(asyncio_subprocesses.popen(
324
+ # 'curl',
325
+ # '-s',
326
+ # '-w', '%{json}',
327
+ # '-H', f'Range: bytes={chunk.offset}-{chunk.offset + chunk.size - 1}',
328
+ # chunk.url,
329
+ # output=subprocess.PIPE,
330
+ # ))
331
+ #
332
+ # futs = asyncio.gather(
333
+ #
334
+ # )
335
+ #
336
+ # await proc.wait()
337
+ #
338
+ # with open(tmp_file, 'r') as f: # noqa
339
+ # curl_json = tmp_file.read()
340
+ #
341
+ # curl_res = json.loads(curl_json.decode().strip())
342
+ #
343
+ # status_code = check.isinstance(curl_res['response_code'], int)
344
+ #
345
+ # if not (200 <= status_code <= 300):
346
+ # raise RuntimeError(f'Curl chunk download {chunk} failed: {curl_res}')
347
+
348
+ async def _download_file_chunk(self, chunk: _DownloadChunk) -> None:
349
+ with log_timing_context(
350
+ 'Downloading github cache '
351
+ f'key {chunk.key} '
352
+ f'file {chunk.out_file} '
353
+ f'chunk {chunk.offset} - {chunk.offset + chunk.size}',
354
+ ):
355
+ await self._download_file_chunk_urllib(chunk)
356
+
357
+ async def _download_file(self, entry: GithubCacheServiceV1BaseClient.Entry, out_file: str) -> None:
358
+ key = check.non_empty_str(entry.artifact.cache_key)
359
+ url = check.non_empty_str(entry.artifact.archive_location)
360
+
361
+ head_resp, _ = await self.send_url_request(urllib.request.Request( # noqa
362
+ url,
363
+ method='HEAD',
364
+ ))
365
+ file_size = int(head_resp.headers['Content-Length'])
366
+
367
+ #
368
+
369
+ with open(out_file, 'xb') as f: # noqa
370
+ f.truncate(file_size)
371
+
372
+ #
373
+
374
+ download_tasks = []
375
+ chunk_size = self._chunk_size
376
+ for i in range((file_size // chunk_size) + (1 if file_size % chunk_size else 0)):
377
+ offset = i * chunk_size
378
+ size = min(chunk_size, file_size - offset)
379
+ chunk = self._DownloadChunk(
380
+ key,
381
+ url,
382
+ out_file,
383
+ offset,
384
+ size,
385
+ )
386
+ download_tasks.append(self._download_file_chunk(chunk))
387
+
388
+ await asyncio_wait_concurrent(download_tasks, self._concurrency)
389
+
390
+ async def download_file(self, entry: GithubCacheClient.Entry, out_file: str) -> None:
391
+ entry1 = check.isinstance(entry, self.Entry)
392
+ with log_timing_context(
393
+ 'Downloading github cache '
394
+ f'key {entry1.artifact.cache_key} '
395
+ f'version {entry1.artifact.cache_version} '
396
+ f'to {out_file}',
397
+ ):
398
+ await self._download_file(entry1, out_file)
399
+
400
+ #
401
+
402
+ async def _upload_file_chunk(
403
+ self,
404
+ key: str,
405
+ cache_id: int,
406
+ in_file: str,
407
+ offset: int,
408
+ size: int,
409
+ ) -> None:
410
+ with log_timing_context(
411
+ f'Uploading github cache {key} '
412
+ f'file {in_file} '
413
+ f'chunk {offset} - {offset + size}',
414
+ ):
415
+ with open(in_file, 'rb') as f: # noqa
416
+ f.seek(offset)
417
+ buf = f.read(size)
418
+
419
+ check.equal(len(buf), size)
420
+
421
+ await self.send_service_request(
422
+ f'caches/{cache_id}',
423
+ method='PATCH',
424
+ content_type='application/octet-stream',
425
+ headers={
426
+ 'Content-Range': f'bytes {offset}-{offset + size - 1}/*',
427
+ },
428
+ content=buf,
429
+ success_status_codes=[204],
430
+ )
431
+
432
+ async def _upload_file(self, key: str, in_file: str) -> None:
433
+ fixed_key = self.fix_key(key)
434
+
435
+ check.state(os.path.isfile(in_file))
436
+
437
+ file_size = os.stat(in_file).st_size
438
+
439
+ #
440
+
441
+ reserve_req = GithubCacheServiceV1.ReserveCacheRequest(
442
+ key=fixed_key,
443
+ cache_size=file_size,
444
+ version=str(self._cache_version),
445
+ )
446
+ reserve_resp_obj = await self.send_service_request(
447
+ 'caches',
448
+ json_content=GithubCacheServiceV1.dataclass_to_json(reserve_req),
449
+ success_status_codes=[201],
450
+ )
451
+ reserve_resp = GithubCacheServiceV1.dataclass_from_json( # noqa
452
+ GithubCacheServiceV1.ReserveCacheResponse,
453
+ reserve_resp_obj,
454
+ )
455
+ cache_id = check.isinstance(reserve_resp.cache_id, int)
456
+
457
+ log.debug(f'Github cache file {os.path.basename(in_file)} got id {cache_id}') # noqa
458
+
459
+ #
460
+
461
+ upload_tasks = []
462
+ chunk_size = self._chunk_size
463
+ for i in range((file_size // chunk_size) + (1 if file_size % chunk_size else 0)):
464
+ offset = i * chunk_size
465
+ size = min(chunk_size, file_size - offset)
466
+ upload_tasks.append(self._upload_file_chunk(
467
+ fixed_key,
468
+ cache_id,
469
+ in_file,
470
+ offset,
471
+ size,
472
+ ))
473
+
474
+ await asyncio_wait_concurrent(upload_tasks, self._concurrency)
475
+
476
+ #
477
+
478
+ commit_req = GithubCacheServiceV1.CommitCacheRequest(
479
+ size=file_size,
480
+ )
481
+ await self.send_service_request(
482
+ f'caches/{cache_id}',
483
+ json_content=GithubCacheServiceV1.dataclass_to_json(commit_req),
484
+ success_status_codes=[204],
485
+ )
486
+
487
+ async def upload_file(self, key: str, in_file: str) -> None:
488
+ with log_timing_context(
489
+ f'Uploading github cache file {os.path.basename(in_file)} '
490
+ f'key {key}',
491
+ ):
492
+ await self._upload_file(key, in_file)
omdev/ci/github/env.py ADDED
@@ -0,0 +1,21 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import dataclasses as dc
3
+ import os
4
+ import typing as ta
5
+
6
+
7
+ @dc.dataclass(frozen=True)
8
+ class GithubEnvVar:
9
+ k: str
10
+
11
+ def __call__(self) -> ta.Optional[str]:
12
+ return os.environ.get(self.k)
13
+
14
+
15
+ GITHUB_ENV_VARS: ta.Set[GithubEnvVar] = set()
16
+
17
+
18
+ def register_github_env_var(k: str) -> GithubEnvVar:
19
+ ev = GithubEnvVar(k)
20
+ GITHUB_ENV_VARS.add(ev)
21
+ return ev
omdev/ci/requirements.py CHANGED
@@ -1,5 +1,4 @@
1
1
  # ruff: noqa: UP006 UP007
2
- # @omlish-lite
3
2
  """
4
3
  TODO:
5
4
  - pip compile lol
omdev/ci/shell.py CHANGED
@@ -1,5 +1,4 @@
1
1
  # ruff: noqa: UP006 UP007
2
- # @omlish-lite
3
2
  import dataclasses as dc
4
3
  import os
5
4
  import typing as ta
omdev/ci/utils.py CHANGED
@@ -1,9 +1,6 @@
1
1
  # ruff: noqa: UP006 UP007
2
- # @omlish-lite
3
2
  import hashlib
4
3
  import logging
5
- import os.path
6
- import tempfile
7
4
  import time
8
5
  import typing as ta
9
6
 
@@ -13,15 +10,6 @@ from omlish.lite.logs import log
13
10
  ##
14
11
 
15
12
 
16
- def make_temp_file() -> str:
17
- file_fd, file = tempfile.mkstemp()
18
- os.close(file_fd)
19
- return file
20
-
21
-
22
- ##
23
-
24
-
25
13
  def read_yaml_file(yaml_file: str) -> ta.Any:
26
14
  yaml = __import__('yaml')
27
15
 
@@ -65,7 +53,7 @@ class LogTimingContext:
65
53
  def __enter__(self) -> 'LogTimingContext':
66
54
  self._begin_time = time.time()
67
55
 
68
- self._log.log(self._level, f'Begin {self._description}') # noqa
56
+ self._log.log(self._level, f'Begin : {self._description}') # noqa
69
57
 
70
58
  return self
71
59
 
@@ -74,7 +62,7 @@ class LogTimingContext:
74
62
 
75
63
  self._log.log(
76
64
  self._level,
77
- f'End {self._description} - {self._end_time - self._begin_time:0.2f} s elapsed',
65
+ f'End : {self._description} - {self._end_time - self._begin_time:0.2f} s elapsed',
78
66
  )
79
67
 
80
68
 
omdev/git/shallow.py CHANGED
@@ -44,7 +44,7 @@ class GitShallowCloner:
44
44
  'clone',
45
45
  '-n',
46
46
  '--depth=1',
47
- *(['--filter=tree:0'] if self.repo_subtrees is not None else []),
47
+ '--filter=tree:0',
48
48
  *(['-b', self.branch] if self.branch else []),
49
49
  '--single-branch',
50
50
  self.repo_url,