omlish 0.0.0.dev23__py3-none-any.whl → 0.0.0.dev24__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.
omlish/__about__.py CHANGED
@@ -1,5 +1,5 @@
1
- __version__ = '0.0.0.dev23'
2
- __revision__ = '1d019b6879a9552ea31622e40f50d260d6b08824'
1
+ __version__ = '0.0.0.dev24'
2
+ __revision__ = '4ab439ef967fda27804fe7d241924c6f08757b54'
3
3
 
4
4
 
5
5
  #
omlish/bootstrap/diag.py CHANGED
@@ -166,12 +166,12 @@ class PycharmBootstrap(SimpleBootstrap['PycharmBootstrap.Config']):
166
166
  class Config(Bootstrap.Config):
167
167
  debug_host: ta.Optional[str] = None
168
168
  debug_port: ta.Optional[int] = None
169
- debug_version: ta.Optional[str] = None
169
+ version: ta.Optional[str] = None
170
170
 
171
171
  def run(self) -> None:
172
172
  if self._config.debug_port is not None:
173
173
  diagpc.pycharm_remote_debugger_attach(
174
174
  self._config.debug_host,
175
175
  self._config.debug_port,
176
- version=self._config.debug_version,
176
+ version=self._config.version,
177
177
  )
omlish/check.py CHANGED
@@ -75,6 +75,9 @@ def enable_args_rendering() -> bool:
75
75
  return True
76
76
 
77
77
 
78
+ enable_args_rendering()
79
+
80
+
78
81
  #
79
82
 
80
83
 
@@ -142,7 +145,14 @@ def _unpack_isinstance_spec(spec: ta.Any) -> tuple:
142
145
 
143
146
  def isinstance(v: ta.Any, spec: type[T] | tuple, msg: Message = None) -> T: # noqa
144
147
  if not _isinstance(v, _unpack_isinstance_spec(spec)):
145
- _raise(TypeError, 'Must be instance', msg, _Args(v, spec))
148
+ _raise(
149
+ TypeError,
150
+ 'Must be instance',
151
+ msg,
152
+ _Args(v, spec),
153
+ render_fmt='not isinstance(%s, %s)',
154
+ )
155
+
146
156
  return v
147
157
 
148
158
 
@@ -155,7 +165,13 @@ def of_isinstance(spec: type[T] | tuple, msg: Message = None) -> ta.Callable[[ta
155
165
 
156
166
  def cast(v: ta.Any, cls: type[T], msg: Message = None) -> T: # noqa
157
167
  if not _isinstance(v, cls):
158
- _raise(TypeError, 'Must be instance', msg, _Args(v, cls))
168
+ _raise(
169
+ TypeError,
170
+ 'Must be instance',
171
+ msg,
172
+ _Args(v, cls),
173
+ )
174
+
159
175
  return v
160
176
 
161
177
 
@@ -168,7 +184,14 @@ def of_cast(cls: type[T], msg: Message = None) -> ta.Callable[[T], T]:
168
184
 
169
185
  def not_isinstance(v: T, spec: ta.Any, msg: Message = None) -> T: # noqa
170
186
  if _isinstance(v, _unpack_isinstance_spec(spec)):
171
- _raise(TypeError, 'Must not be instance', msg, _Args(v, spec))
187
+ _raise(
188
+ TypeError,
189
+ 'Must not be instance',
190
+ msg,
191
+ _Args(v, spec),
192
+ render_fmt='isinstance(%s, %s)',
193
+ )
194
+
172
195
  return v
173
196
 
174
197
 
@@ -184,13 +207,27 @@ def of_not_isinstance(spec: ta.Any, msg: Message = None) -> ta.Callable[[T], T]:
184
207
 
185
208
  def issubclass(v: type[T], spec: ta.Any, msg: Message = None) -> type[T]: # noqa
186
209
  if not _issubclass(v, spec):
187
- _raise(TypeError, 'Must be subclass', msg, _Args(v, spec))
210
+ _raise(
211
+ TypeError,
212
+ 'Must be subclass',
213
+ msg,
214
+ _Args(v, spec),
215
+ render_fmt='not issubclass(%s, %s)',
216
+ )
217
+
188
218
  return v
189
219
 
190
220
 
191
221
  def not_issubclass(v: type[T], spec: ta.Any, msg: Message = None) -> type[T]: # noqa
192
222
  if _issubclass(v, spec):
193
- _raise(TypeError, 'Must not be subclass', msg, _Args(v, spec))
223
+ _raise(
224
+ TypeError,
225
+ 'Must not be subclass',
226
+ msg,
227
+ _Args(v, spec),
228
+ render_fmt='issubclass(%s, %s)',
229
+ )
230
+
194
231
  return v
195
232
 
196
233
 
@@ -199,19 +236,40 @@ def not_issubclass(v: type[T], spec: ta.Any, msg: Message = None) -> type[T]: #
199
236
 
200
237
  def in_(v: T, c: ta.Container[T], msg: Message = None) -> T:
201
238
  if v not in c:
202
- _raise(ValueError, 'Must be in', msg, _Args(v, c))
239
+ _raise(
240
+ ValueError,
241
+ 'Must be in',
242
+ msg,
243
+ _Args(v, c),
244
+ render_fmt='%s not in %s',
245
+ )
246
+
203
247
  return v
204
248
 
205
249
 
206
250
  def not_in(v: T, c: ta.Container[T], msg: Message = None) -> T:
207
251
  if v in c:
208
- _raise(ValueError, 'Must not be in', msg, _Args(v, c))
252
+ _raise(
253
+ ValueError,
254
+ 'Must not be in',
255
+ msg,
256
+ _Args(v, c),
257
+ render_fmt='%s in %s',
258
+ )
259
+
209
260
  return v
210
261
 
211
262
 
212
263
  def empty(v: SizedT, msg: Message = None) -> SizedT:
213
264
  if len(v) != 0:
214
- _raise(ValueError, 'Must be empty', msg, _Args(v))
265
+ _raise(
266
+ ValueError,
267
+ 'Must be empty',
268
+ msg,
269
+ _Args(v),
270
+ render_fmt='%s',
271
+ )
272
+
215
273
  return v
216
274
 
217
275
 
@@ -222,20 +280,40 @@ def iterempty(v: ta.Iterable[T], msg: Message = None) -> ta.Iterable[T]:
222
280
  except StopIteration:
223
281
  pass
224
282
  else:
225
- _raise(ValueError, 'Must be empty', msg, _Args(v))
283
+ _raise(
284
+ ValueError,
285
+ 'Must be empty',
286
+ msg,
287
+ _Args(v),
288
+ render_fmt='%s',
289
+ )
290
+
226
291
  return v
227
292
 
228
293
 
229
294
  def not_empty(v: SizedT, msg: Message = None) -> SizedT:
230
295
  if len(v) == 0:
231
- _raise(ValueError, 'Must not be empty', msg, _Args(v))
296
+ _raise(
297
+ ValueError,
298
+ 'Must not be empty',
299
+ msg,
300
+ _Args(v),
301
+ render_fmt='%s',
302
+ )
303
+
232
304
  return v
233
305
 
234
306
 
235
307
  def unique(it: ta.Iterable[T], msg: Message = None) -> ta.Iterable[T]:
236
308
  dupes = [e for e, c in collections.Counter(it).items() if c > 1]
237
309
  if dupes:
238
- _raise(ValueError, 'Must be unique', msg, _Args(it, dupes))
310
+ _raise(
311
+ ValueError,
312
+ 'Must be unique',
313
+ msg,
314
+ _Args(it, dupes),
315
+ )
316
+
239
317
  return it
240
318
 
241
319
 
@@ -243,9 +321,15 @@ def single(obj: ta.Iterable[T], message: Message = None) -> T:
243
321
  try:
244
322
  [value] = obj
245
323
  except ValueError:
246
- _raise(ValueError, 'Must be single', message, _Args(obj))
247
- else:
248
- return value
324
+ _raise(
325
+ ValueError,
326
+ 'Must be single',
327
+ message,
328
+ _Args(obj),
329
+ render_fmt='%s',
330
+ )
331
+
332
+ return value
249
333
 
250
334
 
251
335
  def optional_single(obj: ta.Iterable[T], message: Message = None) -> T | None:
@@ -254,11 +338,19 @@ def optional_single(obj: ta.Iterable[T], message: Message = None) -> T | None:
254
338
  value = next(it)
255
339
  except StopIteration:
256
340
  return None
341
+
257
342
  try:
258
343
  next(it)
259
344
  except StopIteration:
260
345
  return value # noqa
261
- _raise(ValueError, 'Must be empty or single', message, _Args(obj))
346
+
347
+ _raise(
348
+ ValueError,
349
+ 'Must be empty or single',
350
+ message,
351
+ _Args(obj),
352
+ render_fmt='%s',
353
+ )
262
354
 
263
355
 
264
356
  ##
@@ -266,12 +358,25 @@ def optional_single(obj: ta.Iterable[T], message: Message = None) -> T | None:
266
358
 
267
359
  def none(v: ta.Any, msg: Message = None) -> None:
268
360
  if v is not None:
269
- _raise(ValueError, 'Must be None', msg, _Args(v))
361
+ _raise(
362
+ ValueError,
363
+ 'Must be None',
364
+ msg,
365
+ _Args(v),
366
+ render_fmt='%s',
367
+ )
270
368
 
271
369
 
272
370
  def not_none(v: T | None, msg: Message = None) -> T:
273
371
  if v is None:
274
- _raise(ValueError, 'Must not be None', msg, _Args(v))
372
+ _raise(
373
+ ValueError,
374
+ 'Must not be None',
375
+ msg,
376
+ _Args(v),
377
+ render_fmt='%s',
378
+ )
379
+
275
380
  return v
276
381
 
277
382
 
@@ -280,42 +385,115 @@ def not_none(v: T | None, msg: Message = None) -> T:
280
385
 
281
386
  def equal(v: T, o: ta.Any, msg: Message = None) -> T:
282
387
  if o != v:
283
- _raise(ValueError, 'Must be equal', msg, _Args(v, o), render_fmt='%s != %s')
388
+ _raise(
389
+ ValueError,
390
+ 'Must be equal',
391
+ msg,
392
+ _Args(v, o),
393
+ render_fmt='%s != %s',
394
+ )
395
+
284
396
  return v
285
397
 
286
398
 
287
399
  def is_(v: T, o: ta.Any, msg: Message = None) -> T:
288
400
  if o is not v:
289
- _raise(ValueError, 'Must be the same', msg, _Args(v, o), render_fmt='%s is not %s')
401
+ _raise(
402
+ ValueError,
403
+ 'Must be the same',
404
+ msg,
405
+ _Args(v, o),
406
+ render_fmt='%s is not %s',
407
+ )
408
+
290
409
  return v
291
410
 
292
411
 
293
412
  def is_not(v: T, o: ta.Any, msg: Message = None) -> T:
294
413
  if o is v:
295
- _raise(ValueError, 'Must not be the same', msg, _Args(v, o), render_fmt='%s is %s')
414
+ _raise(
415
+ ValueError,
416
+ 'Must not be the same',
417
+ msg,
418
+ _Args(v, o),
419
+ render_fmt='%s is %s',
420
+ )
421
+
296
422
  return v
297
423
 
298
424
 
299
425
  def callable(v: T, msg: Message = None) -> T: # noqa
300
426
  if not _callable(v):
301
- _raise(TypeError, 'Must be callable', msg, _Args(v))
427
+ _raise(
428
+ TypeError,
429
+ 'Must be callable',
430
+ msg,
431
+ _Args(v),
432
+ render_fmt='%s',
433
+ )
434
+
302
435
  return v # type: ignore
303
436
 
304
437
 
305
438
  def non_empty_str(v: str | None, msg: Message = None) -> str:
306
439
  if not _isinstance(v, str) or not v:
307
- _raise(ValueError, 'Must be non-empty str', msg, _Args(v))
440
+ _raise(
441
+ ValueError,
442
+ 'Must be non-empty str',
443
+ msg,
444
+ _Args(v),
445
+ render_fmt='%s',
446
+ )
447
+
308
448
  return v
309
449
 
310
450
 
451
+ def replacing(expected: ta.Any, old: ta.Any, new: T, msg: Message = None) -> T:
452
+ if old != expected:
453
+ _raise(
454
+ ValueError,
455
+ 'Must be replacing',
456
+ msg,
457
+ _Args(expected, old, new),
458
+ render_fmt='%s -> %s -> %s',
459
+ )
460
+
461
+ return new
462
+
463
+
464
+ def replacing_none(old: ta.Any, new: T, msg: Message = None) -> T:
465
+ if old is not None:
466
+ _raise(
467
+ ValueError,
468
+ 'Must be replacing None',
469
+ msg,
470
+ _Args(old, new),
471
+ render_fmt='%s -> %s',
472
+ )
473
+
474
+ return new
475
+
476
+
311
477
  ##
312
478
 
313
479
 
314
480
  def arg(v: bool, msg: Message = None) -> None:
315
481
  if not v:
316
- _raise(RuntimeError, 'Argument condition not met', msg)
482
+ _raise(
483
+ RuntimeError,
484
+ 'Argument condition not met',
485
+ msg,
486
+ _Args(v),
487
+ render_fmt='%s',
488
+ )
317
489
 
318
490
 
319
491
  def state(v: bool, msg: Message = None) -> None:
320
492
  if not v:
321
- _raise(RuntimeError, 'State condition not met', msg)
493
+ _raise(
494
+ RuntimeError,
495
+ 'State condition not met',
496
+ msg,
497
+ _Args(v),
498
+ render_fmt='%s',
499
+ )
@@ -95,12 +95,12 @@ from .utils import ( # noqa
95
95
  all_not_equal,
96
96
  indexes,
97
97
  key_cmp,
98
+ make_map,
99
+ make_map_by,
98
100
  multi_map,
99
101
  multi_map_by,
100
102
  mut_toposort,
101
103
  partition,
102
104
  toposort,
103
105
  unique,
104
- unique_map,
105
- unique_map_by,
106
106
  )
@@ -72,7 +72,7 @@ def unique(
72
72
  return ret
73
73
 
74
74
 
75
- def unique_map(
75
+ def make_map(
76
76
  kvs: ta.Iterable[tuple[K, V]],
77
77
  *,
78
78
  identity: bool = False,
@@ -88,14 +88,14 @@ def unique_map(
88
88
  return d
89
89
 
90
90
 
91
- def unique_map_by(
91
+ def make_map_by(
92
92
  fn: ta.Callable[[V], K],
93
93
  vs: ta.Iterable[V],
94
94
  *,
95
95
  identity: bool = False,
96
96
  strict: bool = False,
97
97
  ) -> ta.MutableMapping[K, V]:
98
- return unique_map(
98
+ return make_map(
99
99
  ((fn(v), v) for v in vs),
100
100
  identity=identity,
101
101
  strict=strict,
@@ -1,3 +1,8 @@
1
+ """
2
+ An abstraction over greenlet's api. Greenlet doesn't currently support nogil but its functionality is needed for async
3
+ bridge code (both here and in sqlalchemy). This can be implemented with real threads at the expense of overhead, but
4
+ this code is only intended to be used in already fairly heavy situations (bootstrap, db calls).
5
+ """
1
6
  import abc
2
7
  import dataclasses as dc
3
8
  import typing as ta
@@ -94,6 +94,7 @@ from .utils import ( # noqa
94
94
  field_modifier,
95
95
  maybe_post_init,
96
96
  opt_repr,
97
+ update_class_metadata,
97
98
  update_field_extras,
98
99
  update_field_metadata,
99
100
  update_fields,
@@ -130,9 +130,17 @@ class InitBuilder:
130
130
  cas = ', '.join(p.name for p in csig.parameters.values())
131
131
  body_lines.append(f'if not {cn}({cas}): raise __dataclass_CheckError__')
132
132
 
133
- for i, fn in enumerate(self._info.merged_metadata.get(Init, [])):
133
+ inits = self._info.merged_metadata.get(Init, [])
134
+ mro_dct = lang.build_mro_dict(self._info.cls)
135
+ mro_v_ids = set(map(id, mro_dct.values()))
136
+ props_by_fget_id = {id(v.fget): v for v in mro_dct.values() if isinstance(v, property) and v.fget is not None}
137
+ for i, obj in enumerate(inits):
138
+ if (obj_id := id(obj)) not in mro_v_ids and obj_id in props_by_fget_id:
139
+ obj = props_by_fget_id[obj_id].__get__
140
+ elif isinstance(obj, property):
141
+ obj = obj.__get__
134
142
  cn = f'__dataclass_init_{i}__'
135
- locals[cn] = fn
143
+ locals[cn] = obj
136
144
  body_lines.append(f'{cn}({self._self_name})')
137
145
 
138
146
  if not body_lines:
@@ -70,6 +70,6 @@ class Init(lang.Marker):
70
70
  pass
71
71
 
72
72
 
73
- def init(fn: ta.Callable):
74
- _append_cls_md(Init, fn)
75
- return fn
73
+ def init(obj):
74
+ _append_cls_md(Init, obj)
75
+ return obj
@@ -149,7 +149,7 @@ class ClassInfo:
149
149
 
150
150
  @cached.property
151
151
  def generic_mro_lookup(self) -> ta.Mapping[type, rfl.Type]:
152
- return col.unique_map(((check.not_none(rfl.get_concrete_type(g)), g) for g in self.generic_mro), strict=True)
152
+ return col.make_map(((check.not_none(rfl.get_concrete_type(g)), g) for g in self.generic_mro), strict=True)
153
153
 
154
154
  @cached.property
155
155
  def generic_replaced_field_types(self) -> ta.Mapping[str, rfl.Type]:
@@ -4,6 +4,8 @@ import types
4
4
  import typing as ta
5
5
 
6
6
  from .. import check
7
+ from .impl.metadata import METADATA_ATTR
8
+ from .impl.metadata import UserMetadata
7
9
  from .impl.params import DEFAULT_FIELD_EXTRAS
8
10
  from .impl.params import FieldExtras
9
11
  from .impl.params import get_field_extras
@@ -50,6 +52,13 @@ def chain_metadata(*mds: ta.Mapping) -> types.MappingProxyType:
50
52
  return types.MappingProxyType(collections.ChainMap(*mds)) # type: ignore # noqa
51
53
 
52
54
 
55
+ def update_class_metadata(cls: type[T], *args: ta.Any) -> type[T]:
56
+ check.isinstance(cls, type)
57
+ setattr(cls, METADATA_ATTR, md := getattr(cls, METADATA_ATTR, {}))
58
+ md.setdefault(UserMetadata, []).extend(args)
59
+ return cls
60
+
61
+
53
62
  def update_field_metadata(f: dc.Field, nmd: ta.Mapping) -> dc.Field:
54
63
  check.isinstance(f, dc.Field)
55
64
  f.metadata = chain_metadata(nmd, f.metadata)
@@ -79,9 +88,13 @@ def update_fields(
79
88
 
80
89
  else:
81
90
  for a in fields:
82
- v = cls.__dict__[a]
83
- if not isinstance(v, dc.Field):
84
- v = dc.field(default=v)
91
+ try:
92
+ v = cls.__dict__[a]
93
+ except KeyError:
94
+ v = dc.field()
95
+ else:
96
+ if not isinstance(v, dc.Field):
97
+ v = dc.field(default=v)
85
98
  setattr(cls, a, fn(a, v))
86
99
 
87
100
  return cls
omlish/diag/pycharm.py CHANGED
@@ -17,6 +17,16 @@ else:
17
17
  ##
18
18
 
19
19
 
20
+ PYCHARM_HOSTED_ENV_VAR = 'PYCHARM_HOSTED'
21
+
22
+
23
+ def is_pycharm_hosted() -> bool:
24
+ return PYCHARM_HOSTED_ENV_VAR in os.environ
25
+
26
+
27
+ ##
28
+
29
+
20
30
  PYCHARM_HOME = '/Applications/PyCharm.app'
21
31
 
22
32
 
@@ -45,6 +55,53 @@ def get_pycharm_version() -> str | None:
45
55
  ##
46
56
 
47
57
 
58
+ def _import_pydevd_pycharm(*, version: str | None = None) -> ta.Any:
59
+ if (
60
+ 'pydevd_pycharm' in sys.modules or
61
+ (version is None and lang.can_import('pydevd_pycharm'))
62
+ ):
63
+ # Can't unload, nothing we can do
64
+ import pydevd_pycharm # noqa
65
+ return pydevd_pycharm
66
+
67
+ proc = subprocess.run([ # noqa
68
+ sys.executable,
69
+ '-m', 'pip',
70
+ 'show',
71
+ 'pydevd_pycharm',
72
+ ], stdout=subprocess.PIPE)
73
+
74
+ if not proc.returncode:
75
+ info = {
76
+ k: v.strip()
77
+ for l in proc.stdout.decode().splitlines()
78
+ if (s := l.strip())
79
+ for k, _, v in [s.partition(':')]
80
+ }
81
+
82
+ installed_version = info['Version']
83
+ if installed_version == version:
84
+ import pydevd_pycharm # noqa
85
+ return pydevd_pycharm
86
+
87
+ subprocess.check_call([
88
+ sys.executable,
89
+ '-m', 'pip',
90
+ 'uninstall', '-y',
91
+ 'pydevd_pycharm',
92
+ ])
93
+
94
+ subprocess.check_call([
95
+ sys.executable,
96
+ '-m', 'pip',
97
+ 'install',
98
+ 'pydevd_pycharm' + (f'=={version}' if version is not None else ''),
99
+ ])
100
+
101
+ import pydevd_pycharm # noqa
102
+ return pydevd_pycharm
103
+
104
+
48
105
  def pycharm_remote_debugger_attach(
49
106
  host: str | None,
50
107
  port: int,
@@ -56,25 +113,27 @@ def pycharm_remote_debugger_attach(
56
113
  # check.non_empty_str(version)
57
114
 
58
115
  if host is None:
59
- if sys.platform == 'linux' and docker.is_likely_in_docker():
116
+ if (
117
+ sys.platform == 'linux' and
118
+ docker.is_likely_in_docker() and
119
+ docker.get_docker_host_platform() == 'darwin'
120
+ ):
60
121
  host = docker.DOCKER_FOR_MAC_HOSTNAME
61
122
  else:
62
123
  host = 'localhost'
63
124
 
64
- try:
125
+ if ta.TYPE_CHECKING:
65
126
  import pydevd_pycharm # noqa
66
- except ImportError:
67
- subprocess.check_call([
68
- sys.executable,
69
- '-mpip',
70
- 'install',
71
- 'pydevd-pycharm' + (f'~={version}' if version is not None else ''),
72
- ])
127
+ else:
128
+ pydevd_pycharm = _import_pydevd_pycharm(version=version)
73
129
 
74
- import pydevd_pycharm # noqa
75
130
  pydevd_pycharm.settrace(
76
131
  host,
77
132
  port=port,
78
133
  stdoutToServer=True,
79
134
  stderrToServer=True,
80
135
  )
136
+
137
+
138
+ if __name__ == '__main__':
139
+ print(get_pycharm_version())
omlish/docker.py CHANGED
@@ -15,6 +15,7 @@ apil="application/vnd.docker.distribution.manifest.list.v2+json"
15
15
  curl -H "Accept: ${api}" -H "Accept: ${apil}" -H "Authorization: Bearer $token" -s "https://registry-1.docker.io/v2/${repo}/manifests/latest" | jq .
16
16
  """ # noqa
17
17
  import datetime
18
+ import os
18
19
  import re
19
20
  import shlex
20
21
  import subprocess
@@ -38,15 +39,12 @@ else:
38
39
 
39
40
 
40
41
  @dc.dataclass(frozen=True)
42
+ @msh.update_object_metadata(field_naming=msh.Naming.CAMEL, unknown_field='x')
43
+ @msh.update_fields_metadata(['id'], name='ID')
41
44
  class PsItem(lang.Final):
42
- dc.metadata(msh.ObjectMetadata(
43
- field_naming=msh.Naming.CAMEL,
44
- unknown_field='x',
45
- ))
46
-
47
45
  command: str
48
46
  created_at: datetime.datetime
49
- id: str = dc.field(metadata={msh.FieldMetadata: msh.FieldMetadata(name='ID')})
47
+ id: str
50
48
  image: str
51
49
  labels: str
52
50
  local_volumes: str
@@ -101,12 +99,8 @@ def cli_ps() -> list[PsItem]:
101
99
 
102
100
 
103
101
  @dc.dataclass(frozen=True)
102
+ @msh.update_object_metadata(field_naming=msh.Naming.CAMEL, unknown_field='x')
104
103
  class Inspect(lang.Final):
105
- dc.metadata(msh.ObjectMetadata(
106
- field_naming=msh.Naming.CAMEL,
107
- unknown_field='x',
108
- ))
109
-
110
104
  id: str
111
105
  created: datetime.datetime
112
106
 
@@ -186,3 +180,14 @@ def is_likely_in_docker() -> bool:
186
180
  with open('/proc/mounts') as f: # type: ignore
187
181
  ls = f.readlines()
188
182
  return any(_LIKELY_IN_DOCKER_PATTERN.match(l) for l in ls)
183
+
184
+
185
+ ##
186
+
187
+
188
+ # Set by pyproject, docker-dev script
189
+ DOCKER_HOST_PLATFORM_KEY = 'DOCKER_HOST_PLATFORM'
190
+
191
+
192
+ def get_docker_host_platform() -> str | None:
193
+ return os.environ.get(DOCKER_HOST_PLATFORM_KEY)