omdev 0.0.0.dev212__py3-none-any.whl → 0.0.0.dev214__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,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,