ominfra 0.0.0.dev175__py3-none-any.whl → 0.0.0.dev177__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.
ominfra/scripts/manage.py CHANGED
@@ -129,12 +129,12 @@ AtomicPathSwapState = ta.Literal['open', 'committed', 'aborted'] # ta.TypeAlias
129
129
  # ../configs.py
130
130
  ConfigMapping = ta.Mapping[str, ta.Any]
131
131
 
132
- # deploy/specs.py
133
- KeyDeployTagT = ta.TypeVar('KeyDeployTagT', bound='KeyDeployTag')
134
-
135
132
  # ../../omlish/subprocesses.py
136
133
  SubprocessChannelOption = ta.Literal['pipe', 'stdout', 'devnull'] # ta.TypeAlias
137
134
 
135
+ # deploy/specs.py
136
+ KeyDeployTagT = ta.TypeVar('KeyDeployTagT', bound='KeyDeployTag')
137
+
138
138
  # system/packages.py
139
139
  SystemPackageOrStr = ta.Union['SystemPackage', str]
140
140
 
@@ -1384,7 +1384,7 @@ class MainConfig:
1384
1384
 
1385
1385
  @dc.dataclass(frozen=True)
1386
1386
  class DeployConfig:
1387
- deploy_home: ta.Optional[str] = None
1387
+ pass
1388
1388
 
1389
1389
 
1390
1390
  ########################################
@@ -4251,229 +4251,6 @@ def build_command_name_map(crs: CommandRegistrations) -> CommandNameMap:
4251
4251
  return CommandNameMap(dct)
4252
4252
 
4253
4253
 
4254
- ########################################
4255
- # ../deploy/tags.py
4256
-
4257
-
4258
- ##
4259
-
4260
-
4261
- DEPLOY_TAG_SIGIL = '@'
4262
-
4263
- DEPLOY_TAG_SEPARATOR = '--'
4264
-
4265
- DEPLOY_TAG_DELIMITERS: ta.AbstractSet[str] = frozenset([
4266
- DEPLOY_TAG_SEPARATOR,
4267
- '.',
4268
- ])
4269
-
4270
- DEPLOY_TAG_ILLEGAL_STRS: ta.AbstractSet[str] = frozenset([
4271
- DEPLOY_TAG_SIGIL,
4272
- *DEPLOY_TAG_DELIMITERS,
4273
- '/',
4274
- ])
4275
-
4276
-
4277
- ##
4278
-
4279
-
4280
- @dc.dataclass(frozen=True)
4281
- class DeployTag(abc.ABC): # noqa
4282
- s: str
4283
-
4284
- def __post_init__(self) -> None:
4285
- check.not_in(abc.ABC, type(self).__bases__)
4286
- check.non_empty_str(self.s)
4287
- for ch in DEPLOY_TAG_ILLEGAL_STRS:
4288
- check.state(ch not in self.s)
4289
-
4290
- #
4291
-
4292
- tag_name: ta.ClassVar[str]
4293
- tag_kwarg: ta.ClassVar[str]
4294
-
4295
- def __init_subclass__(cls, **kwargs: ta.Any) -> None:
4296
- super().__init_subclass__(**kwargs)
4297
-
4298
- if abc.ABC in cls.__bases__:
4299
- return
4300
-
4301
- for b in cls.__bases__:
4302
- if issubclass(b, DeployTag):
4303
- check.in_(abc.ABC, b.__bases__)
4304
-
4305
- check.non_empty_str(tn := cls.tag_name)
4306
- check.equal(tn, tn.lower().strip())
4307
- check.not_in('_', tn)
4308
-
4309
- check.state(not hasattr(cls, 'tag_kwarg'))
4310
- cls.tag_kwarg = tn.replace('-', '_')
4311
-
4312
-
4313
- ##
4314
-
4315
-
4316
- _DEPLOY_TAGS: ta.Set[ta.Type[DeployTag]] = set()
4317
- DEPLOY_TAGS: ta.AbstractSet[ta.Type[DeployTag]] = _DEPLOY_TAGS
4318
-
4319
- _DEPLOY_TAGS_BY_NAME: ta.Dict[str, ta.Type[DeployTag]] = {}
4320
- DEPLOY_TAGS_BY_NAME: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_NAME
4321
-
4322
- _DEPLOY_TAGS_BY_KWARG: ta.Dict[str, ta.Type[DeployTag]] = {}
4323
- DEPLOY_TAGS_BY_KWARG: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_KWARG
4324
-
4325
-
4326
- def _register_deploy_tag(cls):
4327
- check.not_in(cls.tag_name, _DEPLOY_TAGS_BY_NAME)
4328
- check.not_in(cls.tag_kwarg, _DEPLOY_TAGS_BY_KWARG)
4329
-
4330
- _DEPLOY_TAGS.add(cls)
4331
- _DEPLOY_TAGS_BY_NAME[cls.tag_name] = cls
4332
- _DEPLOY_TAGS_BY_KWARG[cls.tag_kwarg] = cls
4333
-
4334
- return cls
4335
-
4336
-
4337
- ##
4338
-
4339
-
4340
- @_register_deploy_tag
4341
- class DeployTime(DeployTag):
4342
- tag_name: ta.ClassVar[str] = 'time'
4343
-
4344
-
4345
- ##
4346
-
4347
-
4348
- class NameDeployTag(DeployTag, abc.ABC): # noqa
4349
- pass
4350
-
4351
-
4352
- @_register_deploy_tag
4353
- class DeployApp(NameDeployTag):
4354
- tag_name: ta.ClassVar[str] = 'app'
4355
-
4356
-
4357
- @_register_deploy_tag
4358
- class DeployConf(NameDeployTag):
4359
- tag_name: ta.ClassVar[str] = 'conf'
4360
-
4361
-
4362
- ##
4363
-
4364
-
4365
- class KeyDeployTag(DeployTag, abc.ABC): # noqa
4366
- pass
4367
-
4368
-
4369
- @_register_deploy_tag
4370
- class DeployKey(KeyDeployTag):
4371
- tag_name: ta.ClassVar[str] = 'deploy-key'
4372
-
4373
-
4374
- @_register_deploy_tag
4375
- class DeployAppKey(KeyDeployTag):
4376
- tag_name: ta.ClassVar[str] = 'app-key'
4377
-
4378
-
4379
- ##
4380
-
4381
-
4382
- class RevDeployTag(DeployTag, abc.ABC): # noqa
4383
- pass
4384
-
4385
-
4386
- @_register_deploy_tag
4387
- class DeployAppRev(RevDeployTag):
4388
- tag_name: ta.ClassVar[str] = 'app-rev'
4389
-
4390
-
4391
- ##
4392
-
4393
-
4394
- class DeployTagMap:
4395
- def __init__(
4396
- self,
4397
- *args: DeployTag,
4398
- **kwargs: str,
4399
- ) -> None:
4400
- super().__init__()
4401
-
4402
- dct: ta.Dict[ta.Type[DeployTag], DeployTag] = {}
4403
-
4404
- for a in args:
4405
- c = type(check.isinstance(a, DeployTag))
4406
- check.not_in(c, dct)
4407
- dct[c] = a
4408
-
4409
- for k, v in kwargs.items():
4410
- c = DEPLOY_TAGS_BY_KWARG[k]
4411
- check.not_in(c, dct)
4412
- dct[c] = c(v)
4413
-
4414
- self._dct = dct
4415
- self._tup = tuple(sorted((type(t).tag_kwarg, t.s) for t in dct.values()))
4416
-
4417
- #
4418
-
4419
- def add(self, *args: ta.Any, **kwargs: ta.Any) -> 'DeployTagMap':
4420
- return DeployTagMap(
4421
- *self,
4422
- *args,
4423
- **kwargs,
4424
- )
4425
-
4426
- def remove(self, *tags_or_names: ta.Union[ta.Type[DeployTag], str]) -> 'DeployTagMap':
4427
- dcs = {
4428
- check.issubclass(a, DeployTag) if isinstance(a, type) else DEPLOY_TAGS_BY_NAME[a]
4429
- for a in tags_or_names
4430
- }
4431
-
4432
- return DeployTagMap(*[
4433
- t
4434
- for t in self._dct.values()
4435
- if t not in dcs
4436
- ])
4437
-
4438
- #
4439
-
4440
- def __repr__(self) -> str:
4441
- return f'{self.__class__.__name__}({", ".join(f"{k}={v!r}" for k, v in self._tup)})'
4442
-
4443
- def __hash__(self) -> int:
4444
- return hash(self._tup)
4445
-
4446
- def __eq__(self, other: object) -> bool:
4447
- if isinstance(other, DeployTagMap):
4448
- return self._tup == other._tup
4449
- else:
4450
- return NotImplemented
4451
-
4452
- #
4453
-
4454
- def __len__(self) -> int:
4455
- return len(self._dct)
4456
-
4457
- def __iter__(self) -> ta.Iterator[DeployTag]:
4458
- return iter(self._dct.values())
4459
-
4460
- def __getitem__(self, key: ta.Union[ta.Type[DeployTag], str]) -> DeployTag:
4461
- if isinstance(key, str):
4462
- return self._dct[DEPLOY_TAGS_BY_NAME[key]]
4463
- elif isinstance(key, type):
4464
- return self._dct[key]
4465
- else:
4466
- raise TypeError(key)
4467
-
4468
- def __contains__(self, key: ta.Union[ta.Type[DeployTag], str]) -> bool:
4469
- if isinstance(key, str):
4470
- return DEPLOY_TAGS_BY_NAME[key] in self._dct
4471
- elif isinstance(key, type):
4472
- return key in self._dct
4473
- else:
4474
- raise TypeError(key)
4475
-
4476
-
4477
4254
  ########################################
4478
4255
  # ../remote/config.py
4479
4256
 
@@ -6135,6 +5912,18 @@ class FieldsObjMarshaler(ObjMarshaler):
6135
5912
  })
6136
5913
 
6137
5914
 
5915
+ @dc.dataclass(frozen=True)
5916
+ class SingleFieldObjMarshaler(ObjMarshaler):
5917
+ ty: type
5918
+ fld: str
5919
+
5920
+ def marshal(self, o: ta.Any, ctx: 'ObjMarshalContext') -> ta.Any:
5921
+ return getattr(o, self.fld)
5922
+
5923
+ def unmarshal(self, o: ta.Any, ctx: 'ObjMarshalContext') -> ta.Any:
5924
+ return self.ty(**{self.fld: o})
5925
+
5926
+
6138
5927
  @dc.dataclass(frozen=True)
6139
5928
  class PolymorphicObjMarshaler(ObjMarshaler):
6140
5929
  class Impl(ta.NamedTuple):
@@ -6209,7 +5998,7 @@ _DEFAULT_OBJ_MARSHALERS: ta.Dict[ta.Any, ObjMarshaler] = {
6209
5998
  **{t: IterableObjMarshaler(t, DynamicObjMarshaler()) for t in (list, tuple, set, frozenset)},
6210
5999
  **{t: MappingObjMarshaler(t, DynamicObjMarshaler(), DynamicObjMarshaler()) for t in (dict,)},
6211
6000
 
6212
- ta.Any: DynamicObjMarshaler(),
6001
+ **{t: DynamicObjMarshaler() for t in (ta.Any, object)},
6213
6002
 
6214
6003
  **{t: DatetimeObjMarshaler(t) for t in (datetime.date, datetime.time, datetime.datetime)},
6215
6004
  decimal.Decimal: DecimalObjMarshaler(),
@@ -6234,6 +6023,16 @@ _OBJ_MARSHALER_GENERIC_ITERABLE_TYPES: ta.Dict[ta.Any, type] = {
6234
6023
  ##
6235
6024
 
6236
6025
 
6026
+ _REGISTERED_OBJ_MARSHALERS_BY_TYPE: ta.MutableMapping[type, ObjMarshaler] = weakref.WeakKeyDictionary()
6027
+
6028
+
6029
+ def register_type_obj_marshaler(ty: type, om: ObjMarshaler) -> None:
6030
+ _REGISTERED_OBJ_MARSHALERS_BY_TYPE[ty] = om
6031
+
6032
+
6033
+ ##
6034
+
6035
+
6237
6036
  class ObjMarshalerManager:
6238
6037
  def __init__(
6239
6038
  self,
@@ -6243,6 +6042,8 @@ class ObjMarshalerManager:
6243
6042
  default_obj_marshalers: ta.Dict[ta.Any, ObjMarshaler] = _DEFAULT_OBJ_MARSHALERS, # noqa
6244
6043
  generic_mapping_types: ta.Dict[ta.Any, type] = _OBJ_MARSHALER_GENERIC_MAPPING_TYPES, # noqa
6245
6044
  generic_iterable_types: ta.Dict[ta.Any, type] = _OBJ_MARSHALER_GENERIC_ITERABLE_TYPES, # noqa
6045
+
6046
+ registered_obj_marshalers: ta.Mapping[type, ObjMarshaler] = _REGISTERED_OBJ_MARSHALERS_BY_TYPE,
6246
6047
  ) -> None:
6247
6048
  super().__init__()
6248
6049
 
@@ -6251,6 +6052,7 @@ class ObjMarshalerManager:
6251
6052
  self._obj_marshalers = dict(default_obj_marshalers)
6252
6053
  self._generic_mapping_types = generic_mapping_types
6253
6054
  self._generic_iterable_types = generic_iterable_types
6055
+ self._registered_obj_marshalers = registered_obj_marshalers
6254
6056
 
6255
6057
  self._lock = threading.RLock()
6256
6058
  self._marshalers: ta.Dict[ta.Any, ObjMarshaler] = dict(_DEFAULT_OBJ_MARSHALERS)
@@ -6266,6 +6068,9 @@ class ObjMarshalerManager:
6266
6068
  non_strict_fields: bool = False,
6267
6069
  ) -> ObjMarshaler:
6268
6070
  if isinstance(ty, type):
6071
+ if (reg := self._registered_obj_marshalers.get(ty)) is not None:
6072
+ return reg
6073
+
6269
6074
  if abc.ABC in ty.__bases__:
6270
6075
  impls = [ity for ity in deep_subclasses(ty) if abc.ABC not in ity.__bases__] # type: ignore
6271
6076
  if all(ity.__qualname__.endswith(ty.__name__) for ity in impls):
@@ -6330,9 +6135,15 @@ class ObjMarshalerManager:
6330
6135
 
6331
6136
  #
6332
6137
 
6333
- def register_opj_marshaler(self, ty: ta.Any, m: ObjMarshaler) -> None:
6138
+ def set_obj_marshaler(
6139
+ self,
6140
+ ty: ta.Any,
6141
+ m: ObjMarshaler,
6142
+ *,
6143
+ override: bool = False,
6144
+ ) -> None:
6334
6145
  with self._lock:
6335
- if ty in self._obj_marshalers:
6146
+ if not override and ty in self._obj_marshalers:
6336
6147
  raise KeyError(ty)
6337
6148
  self._obj_marshalers[ty] = m
6338
6149
 
@@ -6423,7 +6234,7 @@ class ObjMarshalContext:
6423
6234
 
6424
6235
  OBJ_MARSHALER_MANAGER = ObjMarshalerManager()
6425
6236
 
6426
- register_opj_marshaler = OBJ_MARSHALER_MANAGER.register_opj_marshaler
6237
+ set_obj_marshaler = OBJ_MARSHALER_MANAGER.set_obj_marshaler
6427
6238
  get_obj_marshaler = OBJ_MARSHALER_MANAGER.get_obj_marshaler
6428
6239
 
6429
6240
  marshal_obj = OBJ_MARSHALER_MANAGER.marshal_obj
@@ -6825,6 +6636,7 @@ def read_config_file(
6825
6636
  cls: ta.Type[T],
6826
6637
  *,
6827
6638
  prepare: ta.Optional[ta.Callable[[ConfigMapping], ConfigMapping]] = None,
6639
+ msh: ObjMarshalerManager = OBJ_MARSHALER_MANAGER,
6828
6640
  ) -> T:
6829
6641
  with open(path) as cf:
6830
6642
  config_dct = parse_config_file(os.path.basename(path), cf)
@@ -6832,7 +6644,7 @@ def read_config_file(
6832
6644
  if prepare is not None:
6833
6645
  config_dct = prepare(config_dct)
6834
6646
 
6835
- return unmarshal_obj(config_dct, cls)
6647
+ return msh.unmarshal_obj(config_dct, cls)
6836
6648
 
6837
6649
 
6838
6650
  def build_config_named_children(
@@ -6885,7 +6697,7 @@ def install_command_marshaling(
6885
6697
  lambda c: c,
6886
6698
  lambda c: c.Output,
6887
6699
  ]:
6888
- msh.register_opj_marshaler(
6700
+ msh.set_obj_marshaler(
6889
6701
  fn(Command),
6890
6702
  PolymorphicObjMarshaler.of([
6891
6703
  PolymorphicObjMarshaler.Impl(
@@ -6927,355 +6739,228 @@ CommandExecutorMap = ta.NewType('CommandExecutorMap', ta.Mapping[ta.Type[Command
6927
6739
 
6928
6740
 
6929
6741
  ########################################
6930
- # ../deploy/paths/paths.py
6931
- """
6932
- TODO:
6933
- - run/{.pid,.sock}
6934
- - logs/...
6935
- - current symlink
6936
- - conf/{nginx,supervisor}
6937
- - env/?
6938
- - apps/<app>/shared
6939
- """
6742
+ # ../deploy/tags.py
6940
6743
 
6941
6744
 
6942
6745
  ##
6943
6746
 
6944
6747
 
6945
- class DeployPathError(Exception):
6946
- pass
6947
-
6748
+ DEPLOY_TAG_SIGIL = '@'
6948
6749
 
6949
- class DeployPathRenderable(abc.ABC):
6950
- @abc.abstractmethod
6951
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
6952
- raise NotImplementedError
6750
+ DEPLOY_TAG_SEPARATOR = '--'
6953
6751
 
6752
+ DEPLOY_TAG_DELIMITERS: ta.AbstractSet[str] = frozenset([
6753
+ DEPLOY_TAG_SEPARATOR,
6754
+ '.',
6755
+ ])
6954
6756
 
6955
- ##
6757
+ DEPLOY_TAG_ILLEGAL_STRS: ta.AbstractSet[str] = frozenset([
6758
+ DEPLOY_TAG_SIGIL,
6759
+ *DEPLOY_TAG_DELIMITERS,
6760
+ '/',
6761
+ ])
6956
6762
 
6957
6763
 
6958
- class DeployPathNamePart(DeployPathRenderable, abc.ABC):
6959
- @classmethod
6960
- def parse(cls, s: str) -> 'DeployPathNamePart':
6961
- check.non_empty_str(s)
6962
- if s.startswith(DEPLOY_TAG_SIGIL):
6963
- return TagDeployPathNamePart(s[1:])
6964
- elif s in DEPLOY_TAG_DELIMITERS:
6965
- return DelimiterDeployPathNamePart(s)
6966
- else:
6967
- return ConstDeployPathNamePart(s)
6764
+ ##
6968
6765
 
6969
6766
 
6970
6767
  @dc.dataclass(frozen=True)
6971
- class TagDeployPathNamePart(DeployPathNamePart):
6972
- name: str
6768
+ class DeployTag(abc.ABC): # noqa
6769
+ s: str
6973
6770
 
6974
6771
  def __post_init__(self) -> None:
6975
- check.in_(self.name, DEPLOY_TAGS_BY_NAME)
6772
+ check.not_in(abc.ABC, type(self).__bases__)
6773
+ check.non_empty_str(self.s)
6774
+ for ch in DEPLOY_TAG_ILLEGAL_STRS:
6775
+ check.state(ch not in self.s)
6976
6776
 
6977
- @property
6978
- def tag(self) -> ta.Type[DeployTag]:
6979
- return DEPLOY_TAGS_BY_NAME[self.name]
6777
+ #
6980
6778
 
6981
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
6982
- if tags is not None:
6983
- return tags[self.tag].s
6984
- else:
6985
- return DEPLOY_TAG_SIGIL + self.name
6779
+ tag_name: ta.ClassVar[str]
6780
+ tag_kwarg: ta.ClassVar[str]
6986
6781
 
6782
+ def __init_subclass__(cls, **kwargs: ta.Any) -> None:
6783
+ super().__init_subclass__(**kwargs)
6987
6784
 
6988
- @dc.dataclass(frozen=True)
6989
- class DelimiterDeployPathNamePart(DeployPathNamePart):
6990
- delimiter: str
6785
+ if abc.ABC in cls.__bases__:
6786
+ return
6991
6787
 
6992
- def __post_init__(self) -> None:
6993
- check.in_(self.delimiter, DEPLOY_TAG_DELIMITERS)
6788
+ for b in cls.__bases__:
6789
+ if issubclass(b, DeployTag):
6790
+ check.in_(abc.ABC, b.__bases__)
6994
6791
 
6995
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
6996
- return self.delimiter
6792
+ check.non_empty_str(tn := cls.tag_name)
6793
+ check.equal(tn, tn.lower().strip())
6794
+ check.not_in('_', tn)
6997
6795
 
6796
+ check.state(not hasattr(cls, 'tag_kwarg'))
6797
+ cls.tag_kwarg = tn.replace('-', '_')
6998
6798
 
6999
- @dc.dataclass(frozen=True)
7000
- class ConstDeployPathNamePart(DeployPathNamePart):
7001
- const: str
7002
6799
 
7003
- def __post_init__(self) -> None:
7004
- check.non_empty_str(self.const)
7005
- for c in [*DEPLOY_TAG_DELIMITERS, DEPLOY_TAG_SIGIL, '/']:
7006
- check.not_in(c, self.const)
6800
+ ##
7007
6801
 
7008
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7009
- return self.const
7010
6802
 
6803
+ _DEPLOY_TAGS: ta.Set[ta.Type[DeployTag]] = set()
6804
+ DEPLOY_TAGS: ta.AbstractSet[ta.Type[DeployTag]] = _DEPLOY_TAGS
7011
6805
 
7012
- @dc.dataclass(frozen=True)
7013
- class DeployPathName(DeployPathRenderable):
7014
- parts: ta.Sequence[DeployPathNamePart]
6806
+ _DEPLOY_TAGS_BY_NAME: ta.Dict[str, ta.Type[DeployTag]] = {}
6807
+ DEPLOY_TAGS_BY_NAME: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_NAME
7015
6808
 
7016
- def __post_init__(self) -> None:
7017
- hash(self)
7018
- check.not_empty(self.parts)
7019
- for k, g in itertools.groupby(self.parts, type):
7020
- if len(gl := list(g)) > 1:
7021
- raise DeployPathError(f'May not have consecutive path name part types: {k} {gl}')
6809
+ _DEPLOY_TAGS_BY_KWARG: ta.Dict[str, ta.Type[DeployTag]] = {}
6810
+ DEPLOY_TAGS_BY_KWARG: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_KWARG
7022
6811
 
7023
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7024
- return ''.join(p.render(tags) for p in self.parts)
7025
6812
 
7026
- @classmethod
7027
- def parse(cls, s: str) -> 'DeployPathName':
7028
- check.non_empty_str(s)
7029
- check.not_in('/', s)
6813
+ def _register_deploy_tag(cls):
6814
+ check.not_in(cls.tag_name, _DEPLOY_TAGS_BY_NAME)
6815
+ check.not_in(cls.tag_kwarg, _DEPLOY_TAGS_BY_KWARG)
7030
6816
 
7031
- i = 0
7032
- ps = []
7033
- while i < len(s):
7034
- ns = [(n, d) for d in DEPLOY_TAG_DELIMITERS if (n := s.find(d, i)) >= 0]
7035
- if not ns:
7036
- ps.append(s[i:])
7037
- break
7038
- n, d = min(ns)
7039
- ps.append(check.non_empty_str(s[i:n]))
7040
- ps.append(s[n:n + len(d)])
7041
- i = n + len(d)
6817
+ _DEPLOY_TAGS.add(cls)
6818
+ _DEPLOY_TAGS_BY_NAME[cls.tag_name] = cls
6819
+ _DEPLOY_TAGS_BY_KWARG[cls.tag_kwarg] = cls
7042
6820
 
7043
- return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
6821
+ register_type_obj_marshaler(cls, SingleFieldObjMarshaler(cls, 's'))
6822
+
6823
+ return cls
7044
6824
 
7045
6825
 
7046
6826
  ##
7047
6827
 
7048
6828
 
7049
- @dc.dataclass(frozen=True)
7050
- class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
7051
- name: DeployPathName
6829
+ @_register_deploy_tag
6830
+ class DeployTime(DeployTag):
6831
+ tag_name: ta.ClassVar[str] = 'time'
7052
6832
 
7053
- @property
7054
- @abc.abstractmethod
7055
- def kind(self) -> DeployPathKind:
7056
- raise NotImplementedError
7057
6833
 
7058
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7059
- return self.name.render(tags) + ('/' if self.kind == 'dir' else '')
6834
+ ##
7060
6835
 
7061
- @classmethod
7062
- def parse(cls, s: str) -> 'DeployPathPart':
7063
- if (is_dir := s.endswith('/')):
7064
- s = s[:-1]
7065
- check.non_empty_str(s)
7066
- check.not_in('/', s)
7067
6836
 
7068
- n = DeployPathName.parse(s)
7069
- if is_dir:
7070
- return DirDeployPathPart(n)
7071
- else:
7072
- return FileDeployPathPart(n)
6837
+ class NameDeployTag(DeployTag, abc.ABC): # noqa
6838
+ pass
7073
6839
 
7074
6840
 
7075
- class DirDeployPathPart(DeployPathPart):
7076
- @property
7077
- def kind(self) -> DeployPathKind:
7078
- return 'dir'
6841
+ @_register_deploy_tag
6842
+ class DeployApp(NameDeployTag):
6843
+ tag_name: ta.ClassVar[str] = 'app'
7079
6844
 
7080
6845
 
7081
- class FileDeployPathPart(DeployPathPart):
7082
- @property
7083
- def kind(self) -> DeployPathKind:
7084
- return 'file'
6846
+ @_register_deploy_tag
6847
+ class DeployConf(NameDeployTag):
6848
+ tag_name: ta.ClassVar[str] = 'conf'
7085
6849
 
7086
6850
 
7087
6851
  ##
7088
6852
 
7089
6853
 
7090
- @dc.dataclass(frozen=True)
7091
- class DeployPath:
7092
- parts: ta.Sequence[DeployPathPart]
7093
-
7094
- @property
7095
- def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
7096
- for p in self.parts:
7097
- yield from p.name.parts
7098
-
7099
- def __post_init__(self) -> None:
7100
- hash(self)
7101
- check.not_empty(self.parts)
7102
- for p in self.parts[:-1]:
7103
- check.equal(p.kind, 'dir')
7104
-
7105
- @cached_nullary
7106
- def tag_indices(self) -> ta.Mapping[ta.Type[DeployTag], ta.Sequence[int]]:
7107
- pd: ta.Dict[ta.Type[DeployTag], ta.List[int]] = {}
7108
- for i, np in enumerate(self.name_parts):
7109
- if isinstance(np, TagDeployPathNamePart):
7110
- pd.setdefault(np.tag, []).append(i)
7111
- return pd
7112
-
7113
- @property
7114
- def kind(self) -> DeployPathKind:
7115
- return self.parts[-1].kind
6854
+ class KeyDeployTag(DeployTag, abc.ABC): # noqa
6855
+ pass
7116
6856
 
7117
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7118
- return ''.join([p.render(tags) for p in self.parts])
7119
6857
 
7120
- @classmethod
7121
- def parse(cls, s: str) -> 'DeployPath':
7122
- check.non_empty_str(s)
7123
- ps = split_keep_delimiter(s, '/')
7124
- return cls(tuple(DeployPathPart.parse(p) for p in ps))
6858
+ @_register_deploy_tag
6859
+ class DeployKey(KeyDeployTag):
6860
+ tag_name: ta.ClassVar[str] = 'deploy-key'
7125
6861
 
7126
6862
 
7127
- ########################################
7128
- # ../deploy/specs.py
6863
+ @_register_deploy_tag
6864
+ class DeployAppKey(KeyDeployTag):
6865
+ tag_name: ta.ClassVar[str] = 'app-key'
7129
6866
 
7130
6867
 
7131
6868
  ##
7132
6869
 
7133
6870
 
7134
- def check_valid_deploy_spec_path(s: str) -> str:
7135
- check.non_empty_str(s)
7136
- for c in ['..', '//']:
7137
- check.not_in(c, s)
7138
- check.arg(not s.startswith('/'))
7139
- return s
7140
-
6871
+ class RevDeployTag(DeployTag, abc.ABC): # noqa
6872
+ pass
7141
6873
 
7142
- class DeploySpecKeyed(ta.Generic[KeyDeployTagT]):
7143
- @cached_nullary
7144
- def _key_str(self) -> str:
7145
- return hashlib.sha256(repr(self).encode('utf-8')).hexdigest()[:8]
7146
6874
 
7147
- @abc.abstractmethod
7148
- def key(self) -> KeyDeployTagT:
7149
- raise NotImplementedError
6875
+ @_register_deploy_tag
6876
+ class DeployAppRev(RevDeployTag):
6877
+ tag_name: ta.ClassVar[str] = 'app-rev'
7150
6878
 
7151
6879
 
7152
6880
  ##
7153
6881
 
7154
6882
 
7155
- @dc.dataclass(frozen=True)
7156
- class DeployGitRepo:
7157
- host: ta.Optional[str] = None
7158
- username: ta.Optional[str] = None
7159
- path: ta.Optional[str] = None
6883
+ class DeployTagMap:
6884
+ def __init__(
6885
+ self,
6886
+ *args: DeployTag,
6887
+ **kwargs: str,
6888
+ ) -> None:
6889
+ super().__init__()
7160
6890
 
7161
- def __post_init__(self) -> None:
7162
- check.not_in('..', check.non_empty_str(self.host))
7163
- check.not_in('.', check.non_empty_str(self.path))
6891
+ dct: ta.Dict[ta.Type[DeployTag], DeployTag] = {}
7164
6892
 
6893
+ for a in args:
6894
+ c = type(check.isinstance(a, DeployTag))
6895
+ check.not_in(c, dct)
6896
+ dct[c] = a
7165
6897
 
7166
- @dc.dataclass(frozen=True)
7167
- class DeployGitSpec:
7168
- repo: DeployGitRepo
7169
- rev: DeployRev
7170
-
7171
- subtrees: ta.Optional[ta.Sequence[str]] = None
7172
-
7173
- def __post_init__(self) -> None:
7174
- check.non_empty_str(self.rev)
7175
- if self.subtrees is not None:
7176
- for st in self.subtrees:
7177
- check.non_empty_str(st)
7178
-
7179
-
7180
- ##
7181
-
7182
-
7183
- @dc.dataclass(frozen=True)
7184
- class DeployVenvSpec:
7185
- interp: ta.Optional[str] = None
7186
-
7187
- requirements_files: ta.Optional[ta.Sequence[str]] = None
7188
- extra_dependencies: ta.Optional[ta.Sequence[str]] = None
7189
-
7190
- use_uv: bool = False
7191
-
7192
-
7193
- ##
7194
-
7195
-
7196
- @dc.dataclass(frozen=True)
7197
- class DeployAppConfFile:
7198
- path: str
7199
- body: str
7200
-
7201
- def __post_init__(self) -> None:
7202
- check_valid_deploy_spec_path(self.path)
7203
-
7204
-
7205
- #
7206
-
7207
-
7208
- @dc.dataclass(frozen=True)
7209
- class DeployAppConfLink: # noqa
7210
- """
7211
- May be either:
7212
- - @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7213
- - @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7214
- - @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7215
- """
7216
-
7217
- src: str
7218
-
7219
- kind: ta.Literal['current_only', 'all_active'] = 'current_only'
7220
-
7221
- def __post_init__(self) -> None:
7222
- check_valid_deploy_spec_path(self.src)
7223
- if '/' in self.src:
7224
- check.equal(self.src.count('/'), 1)
7225
-
7226
-
7227
- #
7228
-
7229
-
7230
- @dc.dataclass(frozen=True)
7231
- class DeployAppConfSpec:
7232
- files: ta.Optional[ta.Sequence[DeployAppConfFile]] = None
7233
-
7234
- links: ta.Optional[ta.Sequence[DeployAppConfLink]] = None
7235
-
7236
- def __post_init__(self) -> None:
7237
- if self.files:
7238
- seen: ta.Set[str] = set()
7239
- for f in self.files:
7240
- check.not_in(f.path, seen)
7241
- seen.add(f.path)
6898
+ for k, v in kwargs.items():
6899
+ c = DEPLOY_TAGS_BY_KWARG[k]
6900
+ check.not_in(c, dct)
6901
+ dct[c] = c(v)
7242
6902
 
6903
+ self._dct = dct
6904
+ self._tup = tuple(sorted((type(t).tag_kwarg, t.s) for t in dct.values()))
7243
6905
 
7244
- ##
6906
+ #
7245
6907
 
6908
+ def add(self, *args: ta.Any, **kwargs: ta.Any) -> 'DeployTagMap':
6909
+ return DeployTagMap(
6910
+ *self,
6911
+ *args,
6912
+ **kwargs,
6913
+ )
7246
6914
 
7247
- @dc.dataclass(frozen=True)
7248
- class DeployAppSpec(DeploySpecKeyed[DeployAppKey]):
7249
- app: DeployApp
6915
+ def remove(self, *tags_or_names: ta.Union[ta.Type[DeployTag], str]) -> 'DeployTagMap':
6916
+ dcs = {
6917
+ check.issubclass(a, DeployTag) if isinstance(a, type) else DEPLOY_TAGS_BY_NAME[a]
6918
+ for a in tags_or_names
6919
+ }
7250
6920
 
7251
- git: DeployGitSpec
6921
+ return DeployTagMap(*[
6922
+ t
6923
+ for t in self._dct.values()
6924
+ if t not in dcs
6925
+ ])
7252
6926
 
7253
- venv: ta.Optional[DeployVenvSpec] = None
6927
+ #
7254
6928
 
7255
- conf: ta.Optional[DeployAppConfSpec] = None
6929
+ def __repr__(self) -> str:
6930
+ return f'{self.__class__.__name__}({", ".join(f"{k}={v!r}" for k, v in self._tup)})'
7256
6931
 
7257
- # @ta.override
7258
- def key(self) -> DeployAppKey:
7259
- return DeployAppKey(self._key_str())
6932
+ def __hash__(self) -> int:
6933
+ return hash(self._tup)
7260
6934
 
6935
+ def __eq__(self, other: object) -> bool:
6936
+ if isinstance(other, DeployTagMap):
6937
+ return self._tup == other._tup
6938
+ else:
6939
+ return NotImplemented
7261
6940
 
7262
- ##
6941
+ #
7263
6942
 
6943
+ def __len__(self) -> int:
6944
+ return len(self._dct)
7264
6945
 
7265
- @dc.dataclass(frozen=True)
7266
- class DeploySpec(DeploySpecKeyed[DeployKey]):
7267
- apps: ta.Sequence[DeployAppSpec]
6946
+ def __iter__(self) -> ta.Iterator[DeployTag]:
6947
+ return iter(self._dct.values())
7268
6948
 
7269
- def __post_init__(self) -> None:
7270
- seen: ta.Set[DeployApp] = set()
7271
- for a in self.apps:
7272
- if a.app in seen:
7273
- raise KeyError(a.app)
7274
- seen.add(a.app)
6949
+ def __getitem__(self, key: ta.Union[ta.Type[DeployTag], str]) -> DeployTag:
6950
+ if isinstance(key, str):
6951
+ return self._dct[DEPLOY_TAGS_BY_NAME[key]]
6952
+ elif isinstance(key, type):
6953
+ return self._dct[key]
6954
+ else:
6955
+ raise TypeError(key)
7275
6956
 
7276
- # @ta.override
7277
- def key(self) -> DeployKey:
7278
- return DeployKey(self._key_str())
6957
+ def __contains__(self, key: ta.Union[ta.Type[DeployTag], str]) -> bool:
6958
+ if isinstance(key, str):
6959
+ return DEPLOY_TAGS_BY_NAME[key] in self._dct
6960
+ elif isinstance(key, type):
6961
+ return key in self._dct
6962
+ else:
6963
+ raise TypeError(key)
7279
6964
 
7280
6965
 
7281
6966
  ########################################
@@ -7863,293 +7548,424 @@ class LocalCommandExecutor(CommandExecutor):
7863
7548
 
7864
7549
 
7865
7550
  ########################################
7866
- # ../deploy/conf.py
7551
+ # ../deploy/paths/paths.py
7867
7552
  """
7868
7553
  TODO:
7869
- - @conf DeployPathPlaceholder? :|
7870
- - post-deploy: remove any dir_links not present in new spec
7871
- - * only if succeeded * - otherwise, remove any dir_links present in new spec but not previously present?
7872
- - no such thing as 'previously present'.. build a 'deploy state' and pass it back?
7873
- - ** whole thing can be atomic **
7874
- - 1) new atomic temp dir
7875
- - 2) for each subdir not needing modification, hardlink into temp dir
7876
- - 3) for each subdir needing modification, new subdir, hardlink all files not needing modification
7877
- - 4) write (or if deleting, omit) new files
7878
- - 5) swap top level
7879
- - ** whole deploy can be atomic(-ish) - do this for everything **
7880
- - just a '/deploy/current' dir
7881
- - some things (venvs) cannot be moved, thus the /deploy/venvs dir
7882
- - ** ensure (enforce) equivalent relpath nesting
7554
+ - run/{.pid,.sock}
7555
+ - logs/...
7556
+ - current symlink
7557
+ - conf/{nginx,supervisor}
7558
+ - env/?
7559
+ - apps/<app>/shared
7883
7560
  """
7884
7561
 
7885
7562
 
7886
- class DeployConfManager:
7887
- def __init__(
7888
- self,
7889
- *,
7890
- deploy_home: ta.Optional[DeployHome] = None,
7891
- ) -> None:
7892
- super().__init__()
7893
-
7894
- self._deploy_home = deploy_home
7563
+ ##
7895
7564
 
7896
- #
7897
7565
 
7898
- async def _write_app_conf_file(
7899
- self,
7900
- acf: DeployAppConfFile,
7901
- app_conf_dir: str,
7902
- ) -> None:
7903
- conf_file = os.path.join(app_conf_dir, acf.path)
7904
- check.arg(is_path_in_dir(app_conf_dir, conf_file))
7566
+ class DeployPathError(Exception):
7567
+ pass
7905
7568
 
7906
- os.makedirs(os.path.dirname(conf_file), exist_ok=True)
7907
7569
 
7908
- with open(conf_file, 'w') as f: # noqa
7909
- f.write(acf.body)
7570
+ class DeployPathRenderable(abc.ABC):
7571
+ @abc.abstractmethod
7572
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7573
+ raise NotImplementedError
7910
7574
 
7911
- #
7912
7575
 
7913
- class _ComputedConfLink(ta.NamedTuple):
7914
- conf: DeployConf
7915
- is_dir: bool
7916
- link_src: str
7917
- link_dst: str
7576
+ ##
7918
7577
 
7919
- _UNIQUE_LINK_NAME_STR = '@app--@time--@app-key'
7920
- _UNIQUE_LINK_NAME = DeployPath.parse(_UNIQUE_LINK_NAME_STR)
7921
7578
 
7579
+ class DeployPathNamePart(DeployPathRenderable, abc.ABC):
7922
7580
  @classmethod
7923
- def _compute_app_conf_link_dst(
7924
- cls,
7925
- link: DeployAppConfLink,
7926
- tags: DeployTagMap,
7927
- app_conf_dir: str,
7928
- conf_link_dir: str,
7929
- ) -> _ComputedConfLink:
7930
- link_src = os.path.join(app_conf_dir, link.src)
7931
- check.arg(is_path_in_dir(app_conf_dir, link_src))
7581
+ def parse(cls, s: str) -> 'DeployPathNamePart':
7582
+ check.non_empty_str(s)
7583
+ if s.startswith(DEPLOY_TAG_SIGIL):
7584
+ return TagDeployPathNamePart(s[1:])
7585
+ elif s in DEPLOY_TAG_DELIMITERS:
7586
+ return DelimiterDeployPathNamePart(s)
7587
+ else:
7588
+ return ConstDeployPathNamePart(s)
7932
7589
 
7933
- #
7934
7590
 
7935
- if (is_dir := link.src.endswith('/')):
7936
- # @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7937
- check.arg(link.src.count('/') == 1)
7938
- conf = DeployConf(link.src.split('/')[0])
7939
- link_dst_pfx = link.src
7940
- link_dst_sfx = ''
7591
+ @dc.dataclass(frozen=True)
7592
+ class TagDeployPathNamePart(DeployPathNamePart):
7593
+ name: str
7941
7594
 
7942
- elif '/' in link.src:
7943
- # @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7944
- d, f = os.path.split(link.src)
7945
- # TODO: check filename :|
7946
- conf = DeployConf(d)
7947
- link_dst_pfx = d + '/'
7948
- link_dst_sfx = DEPLOY_TAG_SEPARATOR + f
7595
+ def __post_init__(self) -> None:
7596
+ check.in_(self.name, DEPLOY_TAGS_BY_NAME)
7949
7597
 
7950
- else: # noqa
7951
- # @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7952
- if '.' in link.src:
7953
- l, _, r = link.src.partition('.')
7954
- conf = DeployConf(l)
7955
- link_dst_pfx = l + '/'
7956
- link_dst_sfx = '.' + r
7957
- else:
7958
- conf = DeployConf(link.src)
7959
- link_dst_pfx = link.src + '/'
7960
- link_dst_sfx = ''
7598
+ @property
7599
+ def tag(self) -> ta.Type[DeployTag]:
7600
+ return DEPLOY_TAGS_BY_NAME[self.name]
7961
7601
 
7962
- #
7602
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7603
+ if tags is not None:
7604
+ return tags[self.tag].s
7605
+ else:
7606
+ return DEPLOY_TAG_SIGIL + self.name
7963
7607
 
7964
- if link.kind == 'current_only':
7965
- link_dst_mid = str(tags[DeployApp].s)
7966
- elif link.kind == 'all_active':
7967
- link_dst_mid = cls._UNIQUE_LINK_NAME.render(tags)
7968
- else:
7969
- raise TypeError(link)
7970
7608
 
7971
- #
7609
+ @dc.dataclass(frozen=True)
7610
+ class DelimiterDeployPathNamePart(DeployPathNamePart):
7611
+ delimiter: str
7972
7612
 
7973
- link_dst_name = ''.join([
7974
- link_dst_pfx,
7975
- link_dst_mid,
7976
- link_dst_sfx,
7977
- ])
7978
- link_dst = os.path.join(conf_link_dir, link_dst_name)
7613
+ def __post_init__(self) -> None:
7614
+ check.in_(self.delimiter, DEPLOY_TAG_DELIMITERS)
7979
7615
 
7980
- return DeployConfManager._ComputedConfLink(
7981
- conf=conf,
7982
- is_dir=is_dir,
7983
- link_src=link_src,
7984
- link_dst=link_dst,
7985
- )
7616
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7617
+ return self.delimiter
7986
7618
 
7987
- async def _make_app_conf_link(
7988
- self,
7989
- link: DeployAppConfLink,
7990
- tags: DeployTagMap,
7991
- app_conf_dir: str,
7992
- conf_link_dir: str,
7993
- ) -> None:
7994
- comp = self._compute_app_conf_link_dst(
7995
- link,
7996
- tags,
7997
- app_conf_dir,
7998
- conf_link_dir,
7999
- )
8000
7619
 
8001
- #
7620
+ @dc.dataclass(frozen=True)
7621
+ class ConstDeployPathNamePart(DeployPathNamePart):
7622
+ const: str
8002
7623
 
8003
- check.arg(is_path_in_dir(app_conf_dir, comp.link_src))
8004
- check.arg(is_path_in_dir(conf_link_dir, comp.link_dst))
7624
+ def __post_init__(self) -> None:
7625
+ check.non_empty_str(self.const)
7626
+ for c in [*DEPLOY_TAG_DELIMITERS, DEPLOY_TAG_SIGIL, '/']:
7627
+ check.not_in(c, self.const)
8005
7628
 
8006
- if comp.is_dir:
8007
- check.arg(os.path.isdir(comp.link_src))
8008
- else:
8009
- check.arg(os.path.isfile(comp.link_src))
7629
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7630
+ return self.const
8010
7631
 
8011
- #
8012
7632
 
8013
- relative_symlink( # noqa
8014
- comp.link_src,
8015
- comp.link_dst,
8016
- target_is_directory=comp.is_dir,
8017
- make_dirs=True,
8018
- )
7633
+ @dc.dataclass(frozen=True)
7634
+ class DeployPathName(DeployPathRenderable):
7635
+ parts: ta.Sequence[DeployPathNamePart]
8019
7636
 
8020
- #
7637
+ def __post_init__(self) -> None:
7638
+ hash(self)
7639
+ check.not_empty(self.parts)
7640
+ for k, g in itertools.groupby(self.parts, type):
7641
+ if len(gl := list(g)) > 1:
7642
+ raise DeployPathError(f'May not have consecutive path name part types: {k} {gl}')
8021
7643
 
8022
- async def write_app_conf(
8023
- self,
8024
- spec: DeployAppConfSpec,
8025
- tags: DeployTagMap,
8026
- app_conf_dir: str,
8027
- conf_link_dir: str,
8028
- ) -> None:
8029
- for acf in spec.files or []:
8030
- await self._write_app_conf_file(
8031
- acf,
8032
- app_conf_dir,
8033
- )
7644
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7645
+ return ''.join(p.render(tags) for p in self.parts)
8034
7646
 
8035
- #
7647
+ @classmethod
7648
+ def parse(cls, s: str) -> 'DeployPathName':
7649
+ check.non_empty_str(s)
7650
+ check.not_in('/', s)
8036
7651
 
8037
- for link in spec.links or []:
8038
- await self._make_app_conf_link(
8039
- link,
8040
- tags,
8041
- app_conf_dir,
8042
- conf_link_dir,
8043
- )
7652
+ i = 0
7653
+ ps = []
7654
+ while i < len(s):
7655
+ ns = [(n, d) for d in DEPLOY_TAG_DELIMITERS if (n := s.find(d, i)) >= 0]
7656
+ if not ns:
7657
+ ps.append(s[i:])
7658
+ break
7659
+ n, d = min(ns)
7660
+ ps.append(check.non_empty_str(s[i:n]))
7661
+ ps.append(s[n:n + len(d)])
7662
+ i = n + len(d)
8044
7663
 
7664
+ return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
8045
7665
 
8046
- ########################################
8047
- # ../deploy/paths/owners.py
8048
7666
 
7667
+ ##
8049
7668
 
8050
- class DeployPathOwner(abc.ABC):
7669
+
7670
+ @dc.dataclass(frozen=True)
7671
+ class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
7672
+ name: DeployPathName
7673
+
7674
+ @property
8051
7675
  @abc.abstractmethod
8052
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
7676
+ def kind(self) -> DeployPathKind:
8053
7677
  raise NotImplementedError
8054
7678
 
7679
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7680
+ return self.name.render(tags) + ('/' if self.kind == 'dir' else '')
8055
7681
 
8056
- DeployPathOwners = ta.NewType('DeployPathOwners', ta.Sequence[DeployPathOwner])
7682
+ @classmethod
7683
+ def parse(cls, s: str) -> 'DeployPathPart':
7684
+ if (is_dir := s.endswith('/')):
7685
+ s = s[:-1]
7686
+ check.non_empty_str(s)
7687
+ check.not_in('/', s)
8057
7688
 
7689
+ n = DeployPathName.parse(s)
7690
+ if is_dir:
7691
+ return DirDeployPathPart(n)
7692
+ else:
7693
+ return FileDeployPathPart(n)
8058
7694
 
8059
- class SingleDirDeployPathOwner(DeployPathOwner, abc.ABC):
8060
- def __init__(
8061
- self,
8062
- *args: ta.Any,
8063
- owned_dir: str,
8064
- deploy_home: ta.Optional[DeployHome],
8065
- **kwargs: ta.Any,
8066
- ) -> None:
8067
- super().__init__(*args, **kwargs)
8068
7695
 
8069
- check.not_in('/', owned_dir)
8070
- self._owned_dir: str = check.non_empty_str(owned_dir)
7696
+ class DirDeployPathPart(DeployPathPart):
7697
+ @property
7698
+ def kind(self) -> DeployPathKind:
7699
+ return 'dir'
8071
7700
 
8072
- self._deploy_home = deploy_home
8073
7701
 
8074
- self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
7702
+ class FileDeployPathPart(DeployPathPart):
7703
+ @property
7704
+ def kind(self) -> DeployPathKind:
7705
+ return 'file'
8075
7706
 
8076
- @cached_nullary
8077
- def _dir(self) -> str:
8078
- return os.path.join(check.non_empty_str(self._deploy_home), self._owned_dir)
8079
7707
 
8080
- @cached_nullary
8081
- def _make_dir(self) -> str:
8082
- if not os.path.isdir(d := self._dir()):
8083
- os.makedirs(d, exist_ok=True)
8084
- return d
7708
+ ##
8085
7709
 
8086
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8087
- return self._owned_deploy_paths
8088
7710
 
7711
+ @dc.dataclass(frozen=True)
7712
+ class DeployPath:
7713
+ parts: ta.Sequence[DeployPathPart]
8089
7714
 
8090
- ########################################
8091
- # ../remote/execution.py
8092
- """
8093
- TODO:
8094
- - sequence all messages
8095
- """
7715
+ @property
7716
+ def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
7717
+ for p in self.parts:
7718
+ yield from p.name.parts
8096
7719
 
7720
+ def __post_init__(self) -> None:
7721
+ hash(self)
7722
+ check.not_empty(self.parts)
7723
+ for p in self.parts[:-1]:
7724
+ check.equal(p.kind, 'dir')
8097
7725
 
8098
- ##
7726
+ @cached_nullary
7727
+ def tag_indices(self) -> ta.Mapping[ta.Type[DeployTag], ta.Sequence[int]]:
7728
+ pd: ta.Dict[ta.Type[DeployTag], ta.List[int]] = {}
7729
+ for i, np in enumerate(self.name_parts):
7730
+ if isinstance(np, TagDeployPathNamePart):
7731
+ pd.setdefault(np.tag, []).append(i)
7732
+ return pd
8099
7733
 
7734
+ @property
7735
+ def kind(self) -> DeployPathKind:
7736
+ return self.parts[-1].kind
8100
7737
 
8101
- class _RemoteProtocol:
8102
- class Message(abc.ABC): # noqa
8103
- async def send(self, chan: RemoteChannel) -> None:
8104
- await chan.send_obj(self, _RemoteProtocol.Message)
7738
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7739
+ return ''.join([p.render(tags) for p in self.parts])
8105
7740
 
8106
- @classmethod
8107
- async def recv(cls: ta.Type[T], chan: RemoteChannel) -> ta.Optional[T]:
8108
- return await chan.recv_obj(cls)
7741
+ @classmethod
7742
+ def parse(cls, s: str) -> 'DeployPath':
7743
+ check.non_empty_str(s)
7744
+ ps = split_keep_delimiter(s, '/')
7745
+ return cls(tuple(DeployPathPart.parse(p) for p in ps))
8109
7746
 
8110
- #
8111
7747
 
8112
- class Request(Message, abc.ABC): # noqa
8113
- pass
7748
+ ########################################
7749
+ # ../deploy/specs.py
8114
7750
 
8115
- @dc.dataclass(frozen=True)
8116
- class CommandRequest(Request):
8117
- seq: int
8118
- cmd: Command
8119
7751
 
8120
- @dc.dataclass(frozen=True)
8121
- class PingRequest(Request):
8122
- time: float
7752
+ ##
8123
7753
 
8124
- #
8125
7754
 
8126
- class Response(Message, abc.ABC): # noqa
8127
- pass
7755
+ def check_valid_deploy_spec_path(s: str) -> str:
7756
+ check.non_empty_str(s)
7757
+ for c in ['..', '//']:
7758
+ check.not_in(c, s)
7759
+ check.arg(not s.startswith('/'))
7760
+ return s
8128
7761
 
8129
- @dc.dataclass(frozen=True)
8130
- class LogResponse(Response):
8131
- s: str
8132
7762
 
8133
- @dc.dataclass(frozen=True)
8134
- class CommandResponse(Response):
8135
- seq: int
8136
- res: CommandOutputOrExceptionData
7763
+ class DeploySpecKeyed(ta.Generic[KeyDeployTagT]):
7764
+ @cached_nullary
7765
+ def _key_str(self) -> str:
7766
+ return hashlib.sha256(repr(self).encode('utf-8')).hexdigest()[:8]
8137
7767
 
8138
- @dc.dataclass(frozen=True)
8139
- class PingResponse(Response):
8140
- time: float
7768
+ @abc.abstractmethod
7769
+ def key(self) -> KeyDeployTagT:
7770
+ raise NotImplementedError
8141
7771
 
8142
7772
 
8143
7773
  ##
8144
7774
 
8145
7775
 
8146
- class _RemoteLogHandler(logging.Handler):
8147
- def __init__(
8148
- self,
8149
- chan: RemoteChannel,
8150
- loop: ta.Any = None,
8151
- ) -> None:
8152
- super().__init__()
7776
+ @dc.dataclass(frozen=True)
7777
+ class DeployGitRepo:
7778
+ host: ta.Optional[str] = None
7779
+ username: ta.Optional[str] = None
7780
+ path: ta.Optional[str] = None
7781
+
7782
+ def __post_init__(self) -> None:
7783
+ check.not_in('..', check.non_empty_str(self.host))
7784
+ check.not_in('.', check.non_empty_str(self.path))
7785
+
7786
+
7787
+ @dc.dataclass(frozen=True)
7788
+ class DeployGitSpec:
7789
+ repo: DeployGitRepo
7790
+ rev: DeployRev
7791
+
7792
+ subtrees: ta.Optional[ta.Sequence[str]] = None
7793
+
7794
+ def __post_init__(self) -> None:
7795
+ check.non_empty_str(self.rev)
7796
+ if self.subtrees is not None:
7797
+ for st in self.subtrees:
7798
+ check.non_empty_str(st)
7799
+
7800
+
7801
+ ##
7802
+
7803
+
7804
+ @dc.dataclass(frozen=True)
7805
+ class DeployVenvSpec:
7806
+ interp: ta.Optional[str] = None
7807
+
7808
+ requirements_files: ta.Optional[ta.Sequence[str]] = None
7809
+ extra_dependencies: ta.Optional[ta.Sequence[str]] = None
7810
+
7811
+ use_uv: bool = False
7812
+
7813
+
7814
+ ##
7815
+
7816
+
7817
+ @dc.dataclass(frozen=True)
7818
+ class DeployAppConfFile:
7819
+ path: str
7820
+ body: str
7821
+
7822
+ def __post_init__(self) -> None:
7823
+ check_valid_deploy_spec_path(self.path)
7824
+
7825
+
7826
+ #
7827
+
7828
+
7829
+ @dc.dataclass(frozen=True)
7830
+ class DeployAppConfLink: # noqa
7831
+ """
7832
+ May be either:
7833
+ - @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7834
+ - @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7835
+ - @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7836
+ """
7837
+
7838
+ src: str
7839
+
7840
+ kind: ta.Literal['current_only', 'all_active'] = 'current_only'
7841
+
7842
+ def __post_init__(self) -> None:
7843
+ check_valid_deploy_spec_path(self.src)
7844
+ if '/' in self.src:
7845
+ check.equal(self.src.count('/'), 1)
7846
+
7847
+
7848
+ #
7849
+
7850
+
7851
+ @dc.dataclass(frozen=True)
7852
+ class DeployAppConfSpec:
7853
+ files: ta.Optional[ta.Sequence[DeployAppConfFile]] = None
7854
+
7855
+ links: ta.Optional[ta.Sequence[DeployAppConfLink]] = None
7856
+
7857
+ def __post_init__(self) -> None:
7858
+ if self.files:
7859
+ seen: ta.Set[str] = set()
7860
+ for f in self.files:
7861
+ check.not_in(f.path, seen)
7862
+ seen.add(f.path)
7863
+
7864
+
7865
+ ##
7866
+
7867
+
7868
+ @dc.dataclass(frozen=True)
7869
+ class DeployAppSpec(DeploySpecKeyed[DeployAppKey]):
7870
+ app: DeployApp
7871
+
7872
+ git: DeployGitSpec
7873
+
7874
+ venv: ta.Optional[DeployVenvSpec] = None
7875
+
7876
+ conf: ta.Optional[DeployAppConfSpec] = None
7877
+
7878
+ # @ta.override
7879
+ def key(self) -> DeployAppKey:
7880
+ return DeployAppKey(self._key_str())
7881
+
7882
+
7883
+ ##
7884
+
7885
+
7886
+ @dc.dataclass(frozen=True)
7887
+ class DeploySpec(DeploySpecKeyed[DeployKey]):
7888
+ home: DeployHome
7889
+
7890
+ apps: ta.Sequence[DeployAppSpec]
7891
+
7892
+ def __post_init__(self) -> None:
7893
+ check.non_empty_str(self.home)
7894
+
7895
+ seen: ta.Set[DeployApp] = set()
7896
+ for a in self.apps:
7897
+ if a.app in seen:
7898
+ raise KeyError(a.app)
7899
+ seen.add(a.app)
7900
+
7901
+ # @ta.override
7902
+ def key(self) -> DeployKey:
7903
+ return DeployKey(self._key_str())
7904
+
7905
+
7906
+ ########################################
7907
+ # ../remote/execution.py
7908
+ """
7909
+ TODO:
7910
+ - sequence all messages
7911
+ """
7912
+
7913
+
7914
+ ##
7915
+
7916
+
7917
+ class _RemoteProtocol:
7918
+ class Message(abc.ABC): # noqa
7919
+ async def send(self, chan: RemoteChannel) -> None:
7920
+ await chan.send_obj(self, _RemoteProtocol.Message)
7921
+
7922
+ @classmethod
7923
+ async def recv(cls: ta.Type[T], chan: RemoteChannel) -> ta.Optional[T]:
7924
+ return await chan.recv_obj(cls)
7925
+
7926
+ #
7927
+
7928
+ class Request(Message, abc.ABC): # noqa
7929
+ pass
7930
+
7931
+ @dc.dataclass(frozen=True)
7932
+ class CommandRequest(Request):
7933
+ seq: int
7934
+ cmd: Command
7935
+
7936
+ @dc.dataclass(frozen=True)
7937
+ class PingRequest(Request):
7938
+ time: float
7939
+
7940
+ #
7941
+
7942
+ class Response(Message, abc.ABC): # noqa
7943
+ pass
7944
+
7945
+ @dc.dataclass(frozen=True)
7946
+ class LogResponse(Response):
7947
+ s: str
7948
+
7949
+ @dc.dataclass(frozen=True)
7950
+ class CommandResponse(Response):
7951
+ seq: int
7952
+ res: CommandOutputOrExceptionData
7953
+
7954
+ @dc.dataclass(frozen=True)
7955
+ class PingResponse(Response):
7956
+ time: float
7957
+
7958
+
7959
+ ##
7960
+
7961
+
7962
+ class _RemoteLogHandler(logging.Handler):
7963
+ def __init__(
7964
+ self,
7965
+ chan: RemoteChannel,
7966
+ loop: ta.Any = None,
7967
+ ) -> None:
7968
+ super().__init__()
8153
7969
 
8154
7970
  self._chan = chan
8155
7971
  self._loop = loop
@@ -8874,236 +8690,212 @@ class SubprocessCommandExecutor(CommandExecutor[SubprocessCommand, SubprocessCom
8874
8690
 
8875
8691
 
8876
8692
  ########################################
8877
- # ../deploy/git.py
8693
+ # ../deploy/conf.py
8878
8694
  """
8879
8695
  TODO:
8880
- - 'repos'?
8881
-
8882
- git/github.com/wrmsr/omlish <- bootstrap repo
8883
- - shallow clone off bootstrap into /apps
8884
-
8885
- github.com/wrmsr/omlish@rev
8696
+ - @conf DeployPathPlaceholder? :|
8697
+ - post-deploy: remove any dir_links not present in new spec
8698
+ - * only if succeeded * - otherwise, remove any dir_links present in new spec but not previously present?
8699
+ - no such thing as 'previously present'.. build a 'deploy state' and pass it back?
8700
+ - ** whole thing can be atomic **
8701
+ - 1) new atomic temp dir
8702
+ - 2) for each subdir not needing modification, hardlink into temp dir
8703
+ - 3) for each subdir needing modification, new subdir, hardlink all files not needing modification
8704
+ - 4) write (or if deleting, omit) new files
8705
+ - 5) swap top level
8706
+ - ** whole deploy can be atomic(-ish) - do this for everything **
8707
+ - just a '/deploy/current' dir
8708
+ - some things (venvs) cannot be moved, thus the /deploy/venvs dir
8709
+ - ** ensure (enforce) equivalent relpath nesting
8886
8710
  """
8887
8711
 
8888
8712
 
8889
- ##
8890
-
8891
-
8892
- class DeployGitManager(SingleDirDeployPathOwner):
8893
- def __init__(
8713
+ class DeployConfManager:
8714
+ async def _write_app_conf_file(
8894
8715
  self,
8895
- *,
8896
- deploy_home: ta.Optional[DeployHome] = None,
8897
- atomics: AtomicPathSwapping,
8716
+ acf: DeployAppConfFile,
8717
+ app_conf_dir: str,
8898
8718
  ) -> None:
8899
- super().__init__(
8900
- owned_dir='git',
8901
- deploy_home=deploy_home,
8902
- )
8719
+ conf_file = os.path.join(app_conf_dir, acf.path)
8720
+ check.arg(is_path_in_dir(app_conf_dir, conf_file))
8903
8721
 
8904
- self._atomics = atomics
8722
+ os.makedirs(os.path.dirname(conf_file), exist_ok=True)
8905
8723
 
8906
- self._repo_dirs: ta.Dict[DeployGitRepo, DeployGitManager.RepoDir] = {}
8724
+ with open(conf_file, 'w') as f: # noqa
8725
+ f.write(acf.body)
8907
8726
 
8908
- class RepoDir:
8909
- def __init__(
8910
- self,
8911
- git: 'DeployGitManager',
8912
- repo: DeployGitRepo,
8913
- ) -> None:
8914
- super().__init__()
8727
+ #
8915
8728
 
8916
- self._git = git
8917
- self._repo = repo
8918
- self._dir = os.path.join(
8919
- self._git._make_dir(), # noqa
8920
- check.non_empty_str(repo.host),
8921
- check.non_empty_str(repo.path),
8922
- )
8729
+ class _ComputedConfLink(ta.NamedTuple):
8730
+ conf: DeployConf
8731
+ is_dir: bool
8732
+ link_src: str
8733
+ link_dst: str
8923
8734
 
8924
- @property
8925
- def repo(self) -> DeployGitRepo:
8926
- return self._repo
8735
+ _UNIQUE_LINK_NAME_STR = '@app--@time--@app-key'
8736
+ _UNIQUE_LINK_NAME = DeployPath.parse(_UNIQUE_LINK_NAME_STR)
8927
8737
 
8928
- @property
8929
- def url(self) -> str:
8930
- if self._repo.username is not None:
8931
- return f'{self._repo.username}@{self._repo.host}:{self._repo.path}'
8932
- else:
8933
- return f'https://{self._repo.host}/{self._repo.path}'
8934
-
8935
- #
8936
-
8937
- async def _call(self, *cmd: str) -> None:
8938
- await asyncio_subprocesses.check_call(
8939
- *cmd,
8940
- cwd=self._dir,
8941
- )
8738
+ @classmethod
8739
+ def _compute_app_conf_link_dst(
8740
+ cls,
8741
+ link: DeployAppConfLink,
8742
+ tags: DeployTagMap,
8743
+ app_conf_dir: str,
8744
+ conf_link_dir: str,
8745
+ ) -> _ComputedConfLink:
8746
+ link_src = os.path.join(app_conf_dir, link.src)
8747
+ check.arg(is_path_in_dir(app_conf_dir, link_src))
8942
8748
 
8943
8749
  #
8944
8750
 
8945
- @async_cached_nullary
8946
- async def init(self) -> None:
8947
- os.makedirs(self._dir, exist_ok=True)
8948
- if os.path.exists(os.path.join(self._dir, '.git')):
8949
- return
8751
+ if (is_dir := link.src.endswith('/')):
8752
+ # @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
8753
+ check.arg(link.src.count('/') == 1)
8754
+ conf = DeployConf(link.src.split('/')[0])
8755
+ link_dst_pfx = link.src
8756
+ link_dst_sfx = ''
8950
8757
 
8951
- await self._call('git', 'init')
8952
- await self._call('git', 'remote', 'add', 'origin', self.url)
8758
+ elif '/' in link.src:
8759
+ # @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
8760
+ d, f = os.path.split(link.src)
8761
+ # TODO: check filename :|
8762
+ conf = DeployConf(d)
8763
+ link_dst_pfx = d + '/'
8764
+ link_dst_sfx = DEPLOY_TAG_SEPARATOR + f
8953
8765
 
8954
- async def fetch(self, rev: DeployRev) -> None:
8955
- await self.init()
8956
- await self._call('git', 'fetch', '--depth=1', 'origin', rev)
8766
+ else: # noqa
8767
+ # @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
8768
+ if '.' in link.src:
8769
+ l, _, r = link.src.partition('.')
8770
+ conf = DeployConf(l)
8771
+ link_dst_pfx = l + '/'
8772
+ link_dst_sfx = '.' + r
8773
+ else:
8774
+ conf = DeployConf(link.src)
8775
+ link_dst_pfx = link.src + '/'
8776
+ link_dst_sfx = ''
8957
8777
 
8958
8778
  #
8959
8779
 
8960
- async def checkout(self, spec: DeployGitSpec, dst_dir: str) -> None:
8961
- check.state(not os.path.exists(dst_dir))
8962
- with self._git._atomics.begin_atomic_path_swap( # noqa
8963
- 'dir',
8964
- dst_dir,
8965
- auto_commit=True,
8966
- make_dirs=True,
8967
- ) as dst_swap:
8968
- await self.fetch(spec.rev)
8969
-
8970
- dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_swap.tmp_path)
8971
- await dst_call('git', 'init')
8972
-
8973
- await dst_call('git', 'remote', 'add', 'local', self._dir)
8974
- await dst_call('git', 'fetch', '--depth=1', 'local', spec.rev)
8975
- await dst_call('git', 'checkout', spec.rev, *(spec.subtrees or []))
8976
-
8977
- def get_repo_dir(self, repo: DeployGitRepo) -> RepoDir:
8978
- try:
8979
- return self._repo_dirs[repo]
8980
- except KeyError:
8981
- repo_dir = self._repo_dirs[repo] = DeployGitManager.RepoDir(self, repo)
8982
- return repo_dir
8983
-
8984
- async def checkout(
8985
- self,
8986
- spec: DeployGitSpec,
8987
- dst_dir: str,
8988
- ) -> None:
8989
- await self.get_repo_dir(spec.repo).checkout(spec, dst_dir)
8780
+ if link.kind == 'current_only':
8781
+ link_dst_mid = str(tags[DeployApp].s)
8782
+ elif link.kind == 'all_active':
8783
+ link_dst_mid = cls._UNIQUE_LINK_NAME.render(tags)
8784
+ else:
8785
+ raise TypeError(link)
8990
8786
 
8787
+ #
8991
8788
 
8992
- ########################################
8993
- # ../deploy/paths/manager.py
8789
+ link_dst_name = ''.join([
8790
+ link_dst_pfx,
8791
+ link_dst_mid,
8792
+ link_dst_sfx,
8793
+ ])
8794
+ link_dst = os.path.join(conf_link_dir, link_dst_name)
8994
8795
 
8796
+ return DeployConfManager._ComputedConfLink(
8797
+ conf=conf,
8798
+ is_dir=is_dir,
8799
+ link_src=link_src,
8800
+ link_dst=link_dst,
8801
+ )
8995
8802
 
8996
- class DeployPathsManager:
8997
- def __init__(
8803
+ async def _make_app_conf_link(
8998
8804
  self,
8999
- *,
9000
- deploy_home: ta.Optional[DeployHome],
9001
- deploy_path_owners: DeployPathOwners,
8805
+ link: DeployAppConfLink,
8806
+ tags: DeployTagMap,
8807
+ app_conf_dir: str,
8808
+ conf_link_dir: str,
9002
8809
  ) -> None:
9003
- super().__init__()
8810
+ comp = self._compute_app_conf_link_dst(
8811
+ link,
8812
+ tags,
8813
+ app_conf_dir,
8814
+ conf_link_dir,
8815
+ )
9004
8816
 
9005
- self._deploy_home = deploy_home
9006
- self._deploy_path_owners = deploy_path_owners
8817
+ #
9007
8818
 
9008
- @cached_nullary
9009
- def owners_by_path(self) -> ta.Mapping[DeployPath, DeployPathOwner]:
9010
- dct: ta.Dict[DeployPath, DeployPathOwner] = {}
9011
- for o in self._deploy_path_owners:
9012
- for p in o.get_owned_deploy_paths():
9013
- if p in dct:
9014
- raise DeployPathError(f'Duplicate deploy path owner: {p}')
9015
- dct[p] = o
9016
- return dct
8819
+ check.arg(is_path_in_dir(app_conf_dir, comp.link_src))
8820
+ check.arg(is_path_in_dir(conf_link_dir, comp.link_dst))
9017
8821
 
9018
- def validate_deploy_paths(self) -> None:
9019
- self.owners_by_path()
8822
+ if comp.is_dir:
8823
+ check.arg(os.path.isdir(comp.link_src))
8824
+ else:
8825
+ check.arg(os.path.isfile(comp.link_src))
9020
8826
 
8827
+ #
9021
8828
 
9022
- ########################################
9023
- # ../deploy/tmp.py
8829
+ relative_symlink( # noqa
8830
+ comp.link_src,
8831
+ comp.link_dst,
8832
+ target_is_directory=comp.is_dir,
8833
+ make_dirs=True,
8834
+ )
9024
8835
 
8836
+ #
9025
8837
 
9026
- class DeployTmpManager(
9027
- SingleDirDeployPathOwner,
9028
- AtomicPathSwapping,
9029
- ):
9030
- def __init__(
8838
+ async def write_app_conf(
9031
8839
  self,
9032
- *,
9033
- deploy_home: ta.Optional[DeployHome] = None,
8840
+ spec: DeployAppConfSpec,
8841
+ tags: DeployTagMap,
8842
+ app_conf_dir: str,
8843
+ conf_link_dir: str,
9034
8844
  ) -> None:
9035
- super().__init__(
9036
- owned_dir='tmp',
9037
- deploy_home=deploy_home,
9038
- )
8845
+ for acf in spec.files or []:
8846
+ await self._write_app_conf_file(
8847
+ acf,
8848
+ app_conf_dir,
8849
+ )
9039
8850
 
9040
- @cached_nullary
9041
- def _swapping(self) -> AtomicPathSwapping:
9042
- return TempDirAtomicPathSwapping(
9043
- temp_dir=self._make_dir(),
9044
- root_dir=check.non_empty_str(self._deploy_home),
9045
- )
8851
+ #
9046
8852
 
9047
- def begin_atomic_path_swap(
9048
- self,
9049
- kind: AtomicPathSwapKind,
9050
- dst_path: str,
9051
- **kwargs: ta.Any,
9052
- ) -> AtomicPathSwap:
9053
- return self._swapping().begin_atomic_path_swap(
9054
- kind,
9055
- dst_path,
9056
- **kwargs,
9057
- )
8853
+ for link in spec.links or []:
8854
+ await self._make_app_conf_link(
8855
+ link,
8856
+ tags,
8857
+ app_conf_dir,
8858
+ conf_link_dir,
8859
+ )
9058
8860
 
9059
8861
 
9060
8862
  ########################################
9061
- # ../deploy/venvs.py
9062
- """
9063
- TODO:
9064
- - interp
9065
- - share more code with pyproject?
9066
- """
8863
+ # ../deploy/paths/owners.py
9067
8864
 
9068
8865
 
9069
- class DeployVenvManager:
9070
- def __init__(
9071
- self,
9072
- *,
9073
- atomics: AtomicPathSwapping,
9074
- ) -> None:
9075
- super().__init__()
8866
+ class DeployPathOwner(abc.ABC):
8867
+ @abc.abstractmethod
8868
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8869
+ raise NotImplementedError
9076
8870
 
9077
- self._atomics = atomics
9078
8871
 
9079
- async def setup_venv(
9080
- self,
9081
- spec: DeployVenvSpec,
9082
- git_dir: str,
9083
- venv_dir: str,
9084
- ) -> None:
9085
- sys_exe = 'python3'
8872
+ DeployPathOwners = ta.NewType('DeployPathOwners', ta.Sequence[DeployPathOwner])
9086
8873
 
9087
- # !! NOTE: (most) venvs cannot be relocated, so an atomic swap can't be used. it's up to the path manager to
9088
- # garbage collect orphaned dirs.
9089
- await asyncio_subprocesses.check_call(sys_exe, '-m', 'venv', venv_dir)
9090
8874
 
9091
- #
8875
+ class SingleDirDeployPathOwner(DeployPathOwner, abc.ABC):
8876
+ def __init__(
8877
+ self,
8878
+ *args: ta.Any,
8879
+ owned_dir: str,
8880
+ **kwargs: ta.Any,
8881
+ ) -> None:
8882
+ super().__init__(*args, **kwargs)
9092
8883
 
9093
- venv_exe = os.path.join(venv_dir, 'bin', 'python3')
8884
+ check.not_in('/', owned_dir)
8885
+ self._owned_dir: str = check.non_empty_str(owned_dir)
9094
8886
 
9095
- #
8887
+ self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
9096
8888
 
9097
- reqs_txt = os.path.join(git_dir, 'requirements.txt')
8889
+ def _dir(self, home: DeployHome) -> str:
8890
+ return os.path.join(check.non_empty_str(home), self._owned_dir)
9098
8891
 
9099
- if os.path.isfile(reqs_txt):
9100
- if spec.use_uv:
9101
- await asyncio_subprocesses.check_call(venv_exe, '-m', 'pip', 'install', 'uv')
9102
- pip_cmd = ['-m', 'uv', 'pip']
9103
- else:
9104
- pip_cmd = ['-m', 'pip']
8892
+ def _make_dir(self, home: DeployHome) -> str:
8893
+ if not os.path.isdir(d := self._dir(home)):
8894
+ os.makedirs(d, exist_ok=True)
8895
+ return d
9105
8896
 
9106
- await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
8897
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8898
+ return self._owned_deploy_paths
9107
8899
 
9108
8900
 
9109
8901
  ########################################
@@ -9639,183 +9431,55 @@ def bind_commands(
9639
9431
 
9640
9432
 
9641
9433
  ########################################
9642
- # ../deploy/apps.py
9434
+ # ../deploy/paths/manager.py
9643
9435
 
9644
9436
 
9645
- class DeployAppManager(DeployPathOwner):
9437
+ class DeployPathsManager:
9646
9438
  def __init__(
9647
9439
  self,
9648
9440
  *,
9649
- deploy_home: ta.Optional[DeployHome] = None,
9650
-
9651
- conf: DeployConfManager,
9652
- git: DeployGitManager,
9653
- venvs: DeployVenvManager,
9441
+ deploy_path_owners: DeployPathOwners,
9654
9442
  ) -> None:
9655
9443
  super().__init__()
9656
9444
 
9657
- self._deploy_home = deploy_home
9658
-
9659
- self._conf = conf
9660
- self._git = git
9661
- self._venvs = venvs
9662
-
9663
- #
9664
-
9665
- _APP_DIR_STR = 'apps/@app/@time--@app-rev--@app-key/'
9666
- _APP_DIR = DeployPath.parse(_APP_DIR_STR)
9667
-
9668
- _DEPLOY_DIR_STR = 'deploys/@time--@deploy-key/'
9669
- _DEPLOY_DIR = DeployPath.parse(_DEPLOY_DIR_STR)
9670
-
9671
- _APP_DEPLOY_LINK = DeployPath.parse(f'{_DEPLOY_DIR_STR}apps/@app')
9672
- _CONF_DEPLOY_DIR = DeployPath.parse(f'{_DEPLOY_DIR_STR}conf/@conf/')
9445
+ self._deploy_path_owners = deploy_path_owners
9673
9446
 
9674
9447
  @cached_nullary
9675
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
9676
- return {
9677
- self._APP_DIR,
9678
-
9679
- self._DEPLOY_DIR,
9680
-
9681
- self._APP_DEPLOY_LINK,
9682
- self._CONF_DEPLOY_DIR,
9683
-
9684
- *[
9685
- DeployPath.parse(f'{self._APP_DIR_STR}{sfx}/')
9686
- for sfx in [
9687
- 'conf',
9688
- 'git',
9689
- 'venv',
9690
- ]
9691
- ],
9692
- }
9693
-
9694
- #
9695
-
9696
- async def prepare_app(
9697
- self,
9698
- spec: DeployAppSpec,
9699
- tags: DeployTagMap,
9700
- ) -> None:
9701
- deploy_home = check.non_empty_str(self._deploy_home)
9448
+ def owners_by_path(self) -> ta.Mapping[DeployPath, DeployPathOwner]:
9449
+ dct: ta.Dict[DeployPath, DeployPathOwner] = {}
9450
+ for o in self._deploy_path_owners:
9451
+ for p in o.get_owned_deploy_paths():
9452
+ if p in dct:
9453
+ raise DeployPathError(f'Duplicate deploy path owner: {p}')
9454
+ dct[p] = o
9455
+ return dct
9702
9456
 
9703
- def build_path(pth: DeployPath) -> str:
9704
- return os.path.join(deploy_home, pth.render(tags))
9457
+ def validate_deploy_paths(self) -> None:
9458
+ self.owners_by_path()
9705
9459
 
9706
- app_dir = build_path(self._APP_DIR)
9707
- deploy_dir = build_path(self._DEPLOY_DIR)
9708
- app_deploy_link = build_path(self._APP_DEPLOY_LINK)
9709
9460
 
9710
- #
9461
+ ########################################
9462
+ # ../deploy/tmp.py
9711
9463
 
9712
- os.makedirs(deploy_dir, exist_ok=True)
9713
9464
 
9714
- deploying_link = os.path.join(deploy_home, 'deploys/deploying')
9715
- relative_symlink(
9716
- deploy_dir,
9717
- deploying_link,
9718
- target_is_directory=True,
9719
- make_dirs=True,
9720
- )
9465
+ class DeployHomeAtomics(Func1[DeployHome, AtomicPathSwapping]):
9466
+ pass
9721
9467
 
9722
- #
9723
9468
 
9724
- os.makedirs(app_dir)
9725
- relative_symlink(
9726
- app_dir,
9727
- app_deploy_link,
9728
- target_is_directory=True,
9729
- make_dirs=True,
9469
+ class DeployTmpManager(
9470
+ SingleDirDeployPathOwner,
9471
+ ):
9472
+ def __init__(self) -> None:
9473
+ super().__init__(
9474
+ owned_dir='tmp',
9730
9475
  )
9731
9476
 
9732
- #
9733
-
9734
- deploy_conf_dir = os.path.join(deploy_dir, 'conf')
9735
- os.makedirs(deploy_conf_dir, exist_ok=True)
9736
-
9737
- #
9738
-
9739
- # def mirror_symlinks(src: str, dst: str) -> None:
9740
- # def mirror_link(lp: str) -> None:
9741
- # check.state(os.path.islink(lp))
9742
- # shutil.copy2(
9743
- # lp,
9744
- # os.path.join(dst, os.path.relpath(lp, src)),
9745
- # follow_symlinks=False,
9746
- # )
9747
- #
9748
- # for dp, dns, fns in os.walk(src, followlinks=False):
9749
- # for fn in fns:
9750
- # mirror_link(os.path.join(dp, fn))
9751
- #
9752
- # for dn in dns:
9753
- # dp2 = os.path.join(dp, dn)
9754
- # if os.path.islink(dp2):
9755
- # mirror_link(dp2)
9756
- # else:
9757
- # os.makedirs(os.path.join(dst, os.path.relpath(dp2, src)))
9758
-
9759
- current_link = os.path.join(deploy_home, 'deploys/current')
9760
-
9761
- # if os.path.exists(current_link):
9762
- # mirror_symlinks(
9763
- # os.path.join(current_link, 'conf'),
9764
- # conf_tag_dir,
9765
- # )
9766
- # mirror_symlinks(
9767
- # os.path.join(current_link, 'apps'),
9768
- # os.path.join(deploy_dir, 'apps'),
9769
- # )
9770
-
9771
- #
9772
-
9773
- app_git_dir = os.path.join(app_dir, 'git')
9774
- await self._git.checkout(
9775
- spec.git,
9776
- app_git_dir,
9477
+ def get_swapping(self, home: DeployHome) -> AtomicPathSwapping:
9478
+ return TempDirAtomicPathSwapping(
9479
+ temp_dir=self._make_dir(home),
9480
+ root_dir=check.non_empty_str(home),
9777
9481
  )
9778
9482
 
9779
- #
9780
-
9781
- if spec.venv is not None:
9782
- app_venv_dir = os.path.join(app_dir, 'venv')
9783
- await self._venvs.setup_venv(
9784
- spec.venv,
9785
- app_git_dir,
9786
- app_venv_dir,
9787
- )
9788
-
9789
- #
9790
-
9791
- if spec.conf is not None:
9792
- app_conf_dir = os.path.join(app_dir, 'conf')
9793
- await self._conf.write_app_conf(
9794
- spec.conf,
9795
- tags,
9796
- app_conf_dir,
9797
- deploy_conf_dir,
9798
- )
9799
-
9800
- #
9801
-
9802
- os.replace(deploying_link, current_link)
9803
-
9804
-
9805
- ########################################
9806
- # ../deploy/paths/inject.py
9807
-
9808
-
9809
- def bind_deploy_paths() -> InjectorBindings:
9810
- lst: ta.List[InjectorBindingOrBindings] = [
9811
- inj.bind_array(DeployPathOwner),
9812
- inj.bind_array_type(DeployPathOwner, DeployPathOwners),
9813
-
9814
- inj.bind(DeployPathsManager, singleton=True),
9815
- ]
9816
-
9817
- return inj.as_bindings(*lst)
9818
-
9819
9483
 
9820
9484
  ########################################
9821
9485
  # ../remote/connection.py
@@ -10272,11 +9936,11 @@ class PyenvVersionInstaller:
10272
9936
  full_args = [
10273
9937
  os.path.join(check.not_none(await self._pyenv.root()), 'plugins', 'python-build', 'bin', 'python-build'), # noqa
10274
9938
  *conf_args,
10275
- self.install_dir(),
9939
+ await self.install_dir(),
10276
9940
  ]
10277
9941
  else:
10278
9942
  full_args = [
10279
- self._pyenv.exe(),
9943
+ await self._pyenv.exe(),
10280
9944
  'install',
10281
9945
  *conf_args,
10282
9946
  ]
@@ -10532,90 +10196,489 @@ class SystemInterpProvider(InterpProvider):
10532
10196
 
10533
10197
 
10534
10198
  ########################################
10535
- # ../deploy/deploy.py
10199
+ # ../deploy/git.py
10200
+ """
10201
+ TODO:
10202
+ - 'repos'?
10536
10203
 
10204
+ git/github.com/wrmsr/omlish <- bootstrap repo
10205
+ - shallow clone off bootstrap into /apps
10537
10206
 
10538
- DEPLOY_TAG_DATETIME_FMT = '%Y%m%dT%H%M%SZ'
10207
+ github.com/wrmsr/omlish@rev
10208
+ """
10539
10209
 
10540
10210
 
10541
- DeployManagerUtcClock = ta.NewType('DeployManagerUtcClock', Func0[datetime.datetime])
10211
+ ##
10542
10212
 
10543
10213
 
10544
- class DeployManager:
10214
+ class DeployGitManager(SingleDirDeployPathOwner):
10545
10215
  def __init__(
10546
10216
  self,
10547
10217
  *,
10548
- apps: DeployAppManager,
10549
- paths: DeployPathsManager,
10218
+ atomics: DeployHomeAtomics,
10219
+ ) -> None:
10220
+ super().__init__(
10221
+ owned_dir='git',
10222
+ )
10550
10223
 
10551
- utc_clock: ta.Optional[DeployManagerUtcClock] = None,
10552
- ):
10553
- super().__init__()
10224
+ self._atomics = atomics
10554
10225
 
10555
- self._apps = apps
10556
- self._paths = paths
10226
+ self._repo_dirs: ta.Dict[DeployGitRepo, DeployGitManager.RepoDir] = {}
10557
10227
 
10558
- self._utc_clock = utc_clock
10228
+ class RepoDir:
10229
+ def __init__(
10230
+ self,
10231
+ git: 'DeployGitManager',
10232
+ repo: DeployGitRepo,
10233
+ home: DeployHome,
10234
+ ) -> None:
10235
+ super().__init__()
10559
10236
 
10560
- def _utc_now(self) -> datetime.datetime:
10561
- if self._utc_clock is not None:
10562
- return self._utc_clock() # noqa
10563
- else:
10564
- return datetime.datetime.now(tz=datetime.timezone.utc) # noqa
10237
+ self._git = git
10238
+ self._repo = repo
10239
+ self._home = home
10240
+ self._dir = os.path.join(
10241
+ self._git._make_dir(home), # noqa
10242
+ check.non_empty_str(repo.host),
10243
+ check.non_empty_str(repo.path),
10244
+ )
10565
10245
 
10566
- def _make_deploy_time(self) -> DeployTime:
10567
- return DeployTime(self._utc_now().strftime(DEPLOY_TAG_DATETIME_FMT))
10246
+ @property
10247
+ def repo(self) -> DeployGitRepo:
10248
+ return self._repo
10568
10249
 
10569
- async def run_deploy(
10570
- self,
10571
- spec: DeploySpec,
10572
- ) -> None:
10573
- self._paths.validate_deploy_paths()
10250
+ @property
10251
+ def url(self) -> str:
10252
+ if self._repo.username is not None:
10253
+ return f'{self._repo.username}@{self._repo.host}:{self._repo.path}'
10254
+ else:
10255
+ return f'https://{self._repo.host}/{self._repo.path}'
10574
10256
 
10575
10257
  #
10576
10258
 
10577
- deploy_tags = DeployTagMap(
10578
- self._make_deploy_time(),
10579
- spec.key(),
10580
- )
10259
+ async def _call(self, *cmd: str) -> None:
10260
+ await asyncio_subprocesses.check_call(
10261
+ *cmd,
10262
+ cwd=self._dir,
10263
+ )
10581
10264
 
10582
10265
  #
10583
10266
 
10584
- for app in spec.apps:
10585
- app_tags = deploy_tags.add(
10586
- app.app,
10587
- app.key(),
10588
- DeployAppRev(app.git.rev),
10589
- )
10590
-
10591
- await self._apps.prepare_app(
10592
- app,
10593
- app_tags,
10594
- )
10267
+ @async_cached_nullary
10268
+ async def init(self) -> None:
10269
+ os.makedirs(self._dir, exist_ok=True)
10270
+ if os.path.exists(os.path.join(self._dir, '.git')):
10271
+ return
10595
10272
 
10273
+ await self._call('git', 'init')
10274
+ await self._call('git', 'remote', 'add', 'origin', self.url)
10596
10275
 
10597
- ########################################
10598
- # ../remote/inject.py
10276
+ async def fetch(self, rev: DeployRev) -> None:
10277
+ await self.init()
10278
+ await self._call('git', 'fetch', '--depth=1', 'origin', rev)
10599
10279
 
10280
+ #
10600
10281
 
10601
- def bind_remote(
10602
- *,
10603
- remote_config: RemoteConfig,
10604
- ) -> InjectorBindings:
10282
+ async def checkout(self, spec: DeployGitSpec, dst_dir: str) -> None:
10283
+ check.state(not os.path.exists(dst_dir))
10284
+ with self._git._atomics(self._home).begin_atomic_path_swap( # noqa
10285
+ 'dir',
10286
+ dst_dir,
10287
+ auto_commit=True,
10288
+ make_dirs=True,
10289
+ ) as dst_swap:
10290
+ await self.fetch(spec.rev)
10291
+
10292
+ dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_swap.tmp_path)
10293
+ await dst_call('git', 'init')
10294
+
10295
+ await dst_call('git', 'remote', 'add', 'local', self._dir)
10296
+ await dst_call('git', 'fetch', '--depth=1', 'local', spec.rev)
10297
+ await dst_call('git', 'checkout', spec.rev, *(spec.subtrees or []))
10298
+
10299
+ def get_repo_dir(
10300
+ self,
10301
+ repo: DeployGitRepo,
10302
+ home: DeployHome,
10303
+ ) -> RepoDir:
10304
+ try:
10305
+ return self._repo_dirs[repo]
10306
+ except KeyError:
10307
+ repo_dir = self._repo_dirs[repo] = DeployGitManager.RepoDir(
10308
+ self,
10309
+ repo,
10310
+ home,
10311
+ )
10312
+ return repo_dir
10313
+
10314
+ async def checkout(
10315
+ self,
10316
+ spec: DeployGitSpec,
10317
+ home: DeployHome,
10318
+ dst_dir: str,
10319
+ ) -> None:
10320
+ await self.get_repo_dir(
10321
+ spec.repo,
10322
+ home,
10323
+ ).checkout(
10324
+ spec,
10325
+ dst_dir,
10326
+ )
10327
+
10328
+
10329
+ ########################################
10330
+ # ../deploy/paths/inject.py
10331
+
10332
+
10333
+ def bind_deploy_paths() -> InjectorBindings:
10334
+ lst: ta.List[InjectorBindingOrBindings] = [
10335
+ inj.bind_array(DeployPathOwner),
10336
+ inj.bind_array_type(DeployPathOwner, DeployPathOwners),
10337
+
10338
+ inj.bind(DeployPathsManager, singleton=True),
10339
+ ]
10340
+
10341
+ return inj.as_bindings(*lst)
10342
+
10343
+
10344
+ ########################################
10345
+ # ../remote/inject.py
10346
+
10347
+
10348
+ def bind_remote(
10349
+ *,
10350
+ remote_config: RemoteConfig,
10351
+ ) -> InjectorBindings:
10605
10352
  lst: ta.List[InjectorBindingOrBindings] = [
10606
10353
  inj.bind(remote_config),
10607
10354
 
10608
10355
  inj.bind(SubprocessRemoteSpawning, singleton=True),
10609
10356
  inj.bind(RemoteSpawning, to_key=SubprocessRemoteSpawning),
10610
10357
 
10611
- inj.bind(PyremoteRemoteExecutionConnector, singleton=True),
10612
- inj.bind(InProcessRemoteExecutionConnector, singleton=True),
10358
+ inj.bind(PyremoteRemoteExecutionConnector, singleton=True),
10359
+ inj.bind(InProcessRemoteExecutionConnector, singleton=True),
10360
+ ]
10361
+
10362
+ #
10363
+
10364
+ if (pf := remote_config.payload_file) is not None:
10365
+ lst.append(inj.bind(pf, key=RemoteExecutionPayloadFile))
10366
+
10367
+ #
10368
+
10369
+ return inj.as_bindings(*lst)
10370
+
10371
+
10372
+ ########################################
10373
+ # ../system/inject.py
10374
+
10375
+
10376
+ def bind_system(
10377
+ *,
10378
+ system_config: SystemConfig,
10379
+ ) -> InjectorBindings:
10380
+ lst: ta.List[InjectorBindingOrBindings] = [
10381
+ inj.bind(system_config),
10382
+ ]
10383
+
10384
+ #
10385
+
10386
+ platform = system_config.platform or detect_system_platform()
10387
+ lst.append(inj.bind(platform, key=Platform))
10388
+
10389
+ #
10390
+
10391
+ if isinstance(platform, AmazonLinuxPlatform):
10392
+ lst.extend([
10393
+ inj.bind(YumSystemPackageManager, singleton=True),
10394
+ inj.bind(SystemPackageManager, to_key=YumSystemPackageManager),
10395
+ ])
10396
+
10397
+ elif isinstance(platform, LinuxPlatform):
10398
+ lst.extend([
10399
+ inj.bind(AptSystemPackageManager, singleton=True),
10400
+ inj.bind(SystemPackageManager, to_key=AptSystemPackageManager),
10401
+ ])
10402
+
10403
+ elif isinstance(platform, DarwinPlatform):
10404
+ lst.extend([
10405
+ inj.bind(BrewSystemPackageManager, singleton=True),
10406
+ inj.bind(SystemPackageManager, to_key=BrewSystemPackageManager),
10407
+ ])
10408
+
10409
+ #
10410
+
10411
+ lst.extend([
10412
+ bind_command(CheckSystemPackageCommand, CheckSystemPackageCommandExecutor),
10413
+ ])
10414
+
10415
+ #
10416
+
10417
+ return inj.as_bindings(*lst)
10418
+
10419
+
10420
+ ########################################
10421
+ # ../targets/connection.py
10422
+
10423
+
10424
+ ##
10425
+
10426
+
10427
+ class ManageTargetConnector(abc.ABC):
10428
+ @abc.abstractmethod
10429
+ def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10430
+ raise NotImplementedError
10431
+
10432
+ def _default_python(self, python: ta.Optional[ta.Sequence[str]]) -> ta.Sequence[str]:
10433
+ check.not_isinstance(python, str)
10434
+ if python is not None:
10435
+ return python
10436
+ else:
10437
+ return ['sh', '-c', get_best_python_sh(), '--']
10438
+
10439
+
10440
+ ##
10441
+
10442
+
10443
+ ManageTargetConnectorMap = ta.NewType('ManageTargetConnectorMap', ta.Mapping[ta.Type[ManageTarget], ManageTargetConnector]) # noqa
10444
+
10445
+
10446
+ @dc.dataclass(frozen=True)
10447
+ class TypeSwitchedManageTargetConnector(ManageTargetConnector):
10448
+ connectors: ManageTargetConnectorMap
10449
+
10450
+ def get_connector(self, ty: ta.Type[ManageTarget]) -> ManageTargetConnector:
10451
+ for k, v in self.connectors.items():
10452
+ if issubclass(ty, k):
10453
+ return v
10454
+ raise KeyError(ty)
10455
+
10456
+ def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10457
+ return self.get_connector(type(tgt)).connect(tgt)
10458
+
10459
+
10460
+ ##
10461
+
10462
+
10463
+ @dc.dataclass(frozen=True)
10464
+ class LocalManageTargetConnector(ManageTargetConnector):
10465
+ _local_executor: LocalCommandExecutor
10466
+ _in_process_connector: InProcessRemoteExecutionConnector
10467
+ _pyremote_connector: PyremoteRemoteExecutionConnector
10468
+ _bootstrap: MainBootstrap
10469
+
10470
+ @contextlib.asynccontextmanager
10471
+ async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10472
+ lmt = check.isinstance(tgt, LocalManageTarget)
10473
+
10474
+ if isinstance(lmt, InProcessManageTarget):
10475
+ imt = check.isinstance(lmt, InProcessManageTarget)
10476
+
10477
+ if imt.mode == InProcessManageTarget.Mode.DIRECT:
10478
+ yield self._local_executor
10479
+
10480
+ elif imt.mode == InProcessManageTarget.Mode.FAKE_REMOTE:
10481
+ async with self._in_process_connector.connect() as rce:
10482
+ yield rce
10483
+
10484
+ else:
10485
+ raise TypeError(imt.mode)
10486
+
10487
+ elif isinstance(lmt, SubprocessManageTarget):
10488
+ async with self._pyremote_connector.connect(
10489
+ RemoteSpawning.Target(
10490
+ python=self._default_python(lmt.python),
10491
+ ),
10492
+ self._bootstrap,
10493
+ ) as rce:
10494
+ yield rce
10495
+
10496
+ else:
10497
+ raise TypeError(lmt)
10498
+
10499
+
10500
+ ##
10501
+
10502
+
10503
+ @dc.dataclass(frozen=True)
10504
+ class DockerManageTargetConnector(ManageTargetConnector):
10505
+ _pyremote_connector: PyremoteRemoteExecutionConnector
10506
+ _bootstrap: MainBootstrap
10507
+
10508
+ @contextlib.asynccontextmanager
10509
+ async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10510
+ dmt = check.isinstance(tgt, DockerManageTarget)
10511
+
10512
+ sh_parts: ta.List[str] = ['docker']
10513
+ if dmt.image is not None:
10514
+ sh_parts.extend(['run', '-i', dmt.image])
10515
+ elif dmt.container_id is not None:
10516
+ sh_parts.extend(['exec', '-i', dmt.container_id])
10517
+ else:
10518
+ raise ValueError(dmt)
10519
+
10520
+ async with self._pyremote_connector.connect(
10521
+ RemoteSpawning.Target(
10522
+ shell=' '.join(sh_parts),
10523
+ python=self._default_python(dmt.python),
10524
+ ),
10525
+ self._bootstrap,
10526
+ ) as rce:
10527
+ yield rce
10528
+
10529
+
10530
+ ##
10531
+
10532
+
10533
+ @dc.dataclass(frozen=True)
10534
+ class SshManageTargetConnector(ManageTargetConnector):
10535
+ _pyremote_connector: PyremoteRemoteExecutionConnector
10536
+ _bootstrap: MainBootstrap
10537
+
10538
+ @contextlib.asynccontextmanager
10539
+ async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10540
+ smt = check.isinstance(tgt, SshManageTarget)
10541
+
10542
+ sh_parts: ta.List[str] = ['ssh']
10543
+ if smt.key_file is not None:
10544
+ sh_parts.extend(['-i', smt.key_file])
10545
+ addr = check.not_none(smt.host)
10546
+ if smt.username is not None:
10547
+ addr = f'{smt.username}@{addr}'
10548
+ sh_parts.append(addr)
10549
+
10550
+ async with self._pyremote_connector.connect(
10551
+ RemoteSpawning.Target(
10552
+ shell=' '.join(sh_parts),
10553
+ shell_quote=True,
10554
+ python=self._default_python(smt.python),
10555
+ ),
10556
+ self._bootstrap,
10557
+ ) as rce:
10558
+ yield rce
10559
+
10560
+
10561
+ ########################################
10562
+ # ../../../omdev/interp/resolvers.py
10563
+
10564
+
10565
+ INTERP_PROVIDER_TYPES_BY_NAME: ta.Mapping[str, ta.Type[InterpProvider]] = {
10566
+ cls.name: cls for cls in deep_subclasses(InterpProvider) if abc.ABC not in cls.__bases__ # type: ignore
10567
+ }
10568
+
10569
+
10570
+ class InterpResolver:
10571
+ def __init__(
10572
+ self,
10573
+ providers: ta.Sequence[ta.Tuple[str, InterpProvider]],
10574
+ ) -> None:
10575
+ super().__init__()
10576
+
10577
+ self._providers: ta.Mapping[str, InterpProvider] = collections.OrderedDict(providers)
10578
+
10579
+ async def _resolve_installed(self, spec: InterpSpecifier) -> ta.Optional[ta.Tuple[InterpProvider, InterpVersion]]:
10580
+ lst = [
10581
+ (i, si)
10582
+ for i, p in enumerate(self._providers.values())
10583
+ for si in await p.get_installed_versions(spec)
10584
+ if spec.contains(si)
10585
+ ]
10586
+
10587
+ slst = sorted(lst, key=lambda t: (-t[0], t[1].version))
10588
+ if not slst:
10589
+ return None
10590
+
10591
+ bi, bv = slst[-1]
10592
+ bp = list(self._providers.values())[bi]
10593
+ return (bp, bv)
10594
+
10595
+ async def resolve(
10596
+ self,
10597
+ spec: InterpSpecifier,
10598
+ *,
10599
+ install: bool = False,
10600
+ ) -> ta.Optional[Interp]:
10601
+ tup = await self._resolve_installed(spec)
10602
+ if tup is not None:
10603
+ bp, bv = tup
10604
+ return await bp.get_installed_version(bv)
10605
+
10606
+ if not install:
10607
+ return None
10608
+
10609
+ tp = list(self._providers.values())[0] # noqa
10610
+
10611
+ sv = sorted(
10612
+ [s for s in await tp.get_installable_versions(spec) if s in spec],
10613
+ key=lambda s: s.version,
10614
+ )
10615
+ if not sv:
10616
+ return None
10617
+
10618
+ bv = sv[-1]
10619
+ return await tp.install_version(bv)
10620
+
10621
+ async def list(self, spec: InterpSpecifier) -> None:
10622
+ print('installed:')
10623
+ for n, p in self._providers.items():
10624
+ lst = [
10625
+ si
10626
+ for si in await p.get_installed_versions(spec)
10627
+ if spec.contains(si)
10628
+ ]
10629
+ if lst:
10630
+ print(f' {n}')
10631
+ for si in lst:
10632
+ print(f' {si}')
10633
+
10634
+ print()
10635
+
10636
+ print('installable:')
10637
+ for n, p in self._providers.items():
10638
+ lst = [
10639
+ si
10640
+ for si in await p.get_installable_versions(spec)
10641
+ if spec.contains(si)
10642
+ ]
10643
+ if lst:
10644
+ print(f' {n}')
10645
+ for si in lst:
10646
+ print(f' {si}')
10647
+
10648
+
10649
+ DEFAULT_INTERP_RESOLVER = InterpResolver([(p.name, p) for p in [
10650
+ # pyenv is preferred to system interpreters as it tends to have more support for things like tkinter
10651
+ PyenvInterpProvider(try_update=True),
10652
+
10653
+ RunningInterpProvider(),
10654
+
10655
+ SystemInterpProvider(),
10656
+ ]])
10657
+
10658
+
10659
+ ########################################
10660
+ # ../targets/inject.py
10661
+
10662
+
10663
+ def bind_targets() -> InjectorBindings:
10664
+ lst: ta.List[InjectorBindingOrBindings] = [
10665
+ inj.bind(LocalManageTargetConnector, singleton=True),
10666
+ inj.bind(DockerManageTargetConnector, singleton=True),
10667
+ inj.bind(SshManageTargetConnector, singleton=True),
10668
+
10669
+ inj.bind(TypeSwitchedManageTargetConnector, singleton=True),
10670
+ inj.bind(ManageTargetConnector, to_key=TypeSwitchedManageTargetConnector),
10613
10671
  ]
10614
10672
 
10615
10673
  #
10616
10674
 
10617
- if (pf := remote_config.payload_file) is not None:
10618
- lst.append(inj.bind(pf, key=RemoteExecutionPayloadFile))
10675
+ def provide_manage_target_connector_map(injector: Injector) -> ManageTargetConnectorMap:
10676
+ return ManageTargetConnectorMap({
10677
+ LocalManageTarget: injector[LocalManageTargetConnector],
10678
+ DockerManageTarget: injector[DockerManageTargetConnector],
10679
+ SshManageTarget: injector[SshManageTargetConnector],
10680
+ })
10681
+ lst.append(inj.bind(provide_manage_target_connector_map, singleton=True))
10619
10682
 
10620
10683
  #
10621
10684
 
@@ -10623,290 +10686,319 @@ def bind_remote(
10623
10686
 
10624
10687
 
10625
10688
  ########################################
10626
- # ../system/inject.py
10689
+ # ../deploy/interp.py
10627
10690
 
10628
10691
 
10629
- def bind_system(
10630
- *,
10631
- system_config: SystemConfig,
10632
- ) -> InjectorBindings:
10633
- lst: ta.List[InjectorBindingOrBindings] = [
10634
- inj.bind(system_config),
10635
- ]
10692
+ ##
10636
10693
 
10637
- #
10638
10694
 
10639
- platform = system_config.platform or detect_system_platform()
10640
- lst.append(inj.bind(platform, key=Platform))
10695
+ @dc.dataclass(frozen=True)
10696
+ class InterpCommand(Command['InterpCommand.Output']):
10697
+ spec: str
10698
+ install: bool = False
10641
10699
 
10642
- #
10700
+ @dc.dataclass(frozen=True)
10701
+ class Output(Command.Output):
10702
+ exe: str
10703
+ version: str
10704
+ opts: InterpOpts
10643
10705
 
10644
- if isinstance(platform, AmazonLinuxPlatform):
10645
- lst.extend([
10646
- inj.bind(YumSystemPackageManager, singleton=True),
10647
- inj.bind(SystemPackageManager, to_key=YumSystemPackageManager),
10648
- ])
10649
10706
 
10650
- elif isinstance(platform, LinuxPlatform):
10651
- lst.extend([
10652
- inj.bind(AptSystemPackageManager, singleton=True),
10653
- inj.bind(SystemPackageManager, to_key=AptSystemPackageManager),
10654
- ])
10707
+ class InterpCommandExecutor(CommandExecutor[InterpCommand, InterpCommand.Output]):
10708
+ async def execute(self, cmd: InterpCommand) -> InterpCommand.Output:
10709
+ i = InterpSpecifier.parse(check.not_none(cmd.spec))
10710
+ o = check.not_none(await DEFAULT_INTERP_RESOLVER.resolve(i, install=cmd.install))
10711
+ return InterpCommand.Output(
10712
+ exe=o.exe,
10713
+ version=str(o.version.version),
10714
+ opts=o.version.opts,
10715
+ )
10655
10716
 
10656
- elif isinstance(platform, DarwinPlatform):
10657
- lst.extend([
10658
- inj.bind(BrewSystemPackageManager, singleton=True),
10659
- inj.bind(SystemPackageManager, to_key=BrewSystemPackageManager),
10660
- ])
10661
10717
 
10662
- #
10718
+ ########################################
10719
+ # ../deploy/venvs.py
10720
+ """
10721
+ TODO:
10722
+ - interp
10723
+ - share more code with pyproject?
10724
+ """
10663
10725
 
10664
- lst.extend([
10665
- bind_command(CheckSystemPackageCommand, CheckSystemPackageCommandExecutor),
10666
- ])
10667
10726
 
10668
- #
10727
+ class DeployVenvManager:
10728
+ async def setup_venv(
10729
+ self,
10730
+ spec: DeployVenvSpec,
10731
+ home: DeployHome,
10732
+ git_dir: str,
10733
+ venv_dir: str,
10734
+ ) -> None:
10735
+ if spec.interp is not None:
10736
+ i = InterpSpecifier.parse(check.not_none(spec.interp))
10737
+ o = check.not_none(await DEFAULT_INTERP_RESOLVER.resolve(i))
10738
+ sys_exe = o.exe
10739
+ else:
10740
+ sys_exe = 'python3'
10669
10741
 
10670
- return inj.as_bindings(*lst)
10742
+ #
10743
+
10744
+ # !! NOTE: (most) venvs cannot be relocated, so an atomic swap can't be used. it's up to the path manager to
10745
+ # garbage collect orphaned dirs.
10746
+ await asyncio_subprocesses.check_call(sys_exe, '-m', 'venv', venv_dir)
10671
10747
 
10748
+ #
10672
10749
 
10673
- ########################################
10674
- # ../targets/connection.py
10750
+ venv_exe = os.path.join(venv_dir, 'bin', 'python3')
10675
10751
 
10752
+ #
10676
10753
 
10677
- ##
10754
+ reqs_txt = os.path.join(git_dir, 'requirements.txt')
10678
10755
 
10756
+ if os.path.isfile(reqs_txt):
10757
+ if spec.use_uv:
10758
+ await asyncio_subprocesses.check_call(venv_exe, '-m', 'pip', 'install', 'uv')
10759
+ pip_cmd = ['-m', 'uv', 'pip']
10760
+ else:
10761
+ pip_cmd = ['-m', 'pip']
10679
10762
 
10680
- class ManageTargetConnector(abc.ABC):
10681
- @abc.abstractmethod
10682
- def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10683
- raise NotImplementedError
10763
+ await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
10684
10764
 
10685
- def _default_python(self, python: ta.Optional[ta.Sequence[str]]) -> ta.Sequence[str]:
10686
- check.not_isinstance(python, str)
10687
- if python is not None:
10688
- return python
10689
- else:
10690
- return ['sh', '-c', get_best_python_sh(), '--']
10691
10765
 
10766
+ ########################################
10767
+ # ../deploy/apps.py
10692
10768
 
10693
- ##
10694
10769
 
10770
+ class DeployAppManager(DeployPathOwner):
10771
+ def __init__(
10772
+ self,
10773
+ *,
10774
+ conf: DeployConfManager,
10775
+ git: DeployGitManager,
10776
+ venvs: DeployVenvManager,
10777
+ ) -> None:
10778
+ super().__init__()
10695
10779
 
10696
- ManageTargetConnectorMap = ta.NewType('ManageTargetConnectorMap', ta.Mapping[ta.Type[ManageTarget], ManageTargetConnector]) # noqa
10780
+ self._conf = conf
10781
+ self._git = git
10782
+ self._venvs = venvs
10697
10783
 
10784
+ #
10698
10785
 
10699
- @dc.dataclass(frozen=True)
10700
- class TypeSwitchedManageTargetConnector(ManageTargetConnector):
10701
- connectors: ManageTargetConnectorMap
10786
+ _APP_DIR_STR = 'apps/@app/@time--@app-rev--@app-key/'
10787
+ _APP_DIR = DeployPath.parse(_APP_DIR_STR)
10702
10788
 
10703
- def get_connector(self, ty: ta.Type[ManageTarget]) -> ManageTargetConnector:
10704
- for k, v in self.connectors.items():
10705
- if issubclass(ty, k):
10706
- return v
10707
- raise KeyError(ty)
10789
+ _DEPLOY_DIR_STR = 'deploys/@time--@deploy-key/'
10790
+ _DEPLOY_DIR = DeployPath.parse(_DEPLOY_DIR_STR)
10708
10791
 
10709
- def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10710
- return self.get_connector(type(tgt)).connect(tgt)
10792
+ _APP_DEPLOY_LINK = DeployPath.parse(f'{_DEPLOY_DIR_STR}apps/@app')
10793
+ _CONF_DEPLOY_DIR = DeployPath.parse(f'{_DEPLOY_DIR_STR}conf/@conf/')
10711
10794
 
10795
+ @cached_nullary
10796
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
10797
+ return {
10798
+ self._APP_DIR,
10712
10799
 
10713
- ##
10800
+ self._DEPLOY_DIR,
10714
10801
 
10802
+ self._APP_DEPLOY_LINK,
10803
+ self._CONF_DEPLOY_DIR,
10715
10804
 
10716
- @dc.dataclass(frozen=True)
10717
- class LocalManageTargetConnector(ManageTargetConnector):
10718
- _local_executor: LocalCommandExecutor
10719
- _in_process_connector: InProcessRemoteExecutionConnector
10720
- _pyremote_connector: PyremoteRemoteExecutionConnector
10721
- _bootstrap: MainBootstrap
10805
+ *[
10806
+ DeployPath.parse(f'{self._APP_DIR_STR}{sfx}/')
10807
+ for sfx in [
10808
+ 'conf',
10809
+ 'git',
10810
+ 'venv',
10811
+ ]
10812
+ ],
10813
+ }
10722
10814
 
10723
- @contextlib.asynccontextmanager
10724
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10725
- lmt = check.isinstance(tgt, LocalManageTarget)
10815
+ #
10726
10816
 
10727
- if isinstance(lmt, InProcessManageTarget):
10728
- imt = check.isinstance(lmt, InProcessManageTarget)
10817
+ async def prepare_app(
10818
+ self,
10819
+ spec: DeployAppSpec,
10820
+ home: DeployHome,
10821
+ tags: DeployTagMap,
10822
+ ) -> None:
10823
+ check.non_empty_str(home)
10729
10824
 
10730
- if imt.mode == InProcessManageTarget.Mode.DIRECT:
10731
- yield self._local_executor
10825
+ def build_path(pth: DeployPath) -> str:
10826
+ return os.path.join(home, pth.render(tags))
10732
10827
 
10733
- elif imt.mode == InProcessManageTarget.Mode.FAKE_REMOTE:
10734
- async with self._in_process_connector.connect() as rce:
10735
- yield rce
10828
+ app_dir = build_path(self._APP_DIR)
10829
+ deploy_dir = build_path(self._DEPLOY_DIR)
10830
+ app_deploy_link = build_path(self._APP_DEPLOY_LINK)
10736
10831
 
10737
- else:
10738
- raise TypeError(imt.mode)
10832
+ #
10739
10833
 
10740
- elif isinstance(lmt, SubprocessManageTarget):
10741
- async with self._pyremote_connector.connect(
10742
- RemoteSpawning.Target(
10743
- python=self._default_python(lmt.python),
10744
- ),
10745
- self._bootstrap,
10746
- ) as rce:
10747
- yield rce
10834
+ os.makedirs(deploy_dir, exist_ok=True)
10748
10835
 
10749
- else:
10750
- raise TypeError(lmt)
10836
+ deploying_link = os.path.join(home, 'deploys/deploying')
10837
+ if os.path.exists(deploying_link):
10838
+ os.unlink(deploying_link)
10839
+ relative_symlink(
10840
+ deploy_dir,
10841
+ deploying_link,
10842
+ target_is_directory=True,
10843
+ make_dirs=True,
10844
+ )
10751
10845
 
10846
+ #
10752
10847
 
10753
- ##
10848
+ os.makedirs(app_dir)
10849
+ relative_symlink(
10850
+ app_dir,
10851
+ app_deploy_link,
10852
+ target_is_directory=True,
10853
+ make_dirs=True,
10854
+ )
10754
10855
 
10856
+ #
10755
10857
 
10756
- @dc.dataclass(frozen=True)
10757
- class DockerManageTargetConnector(ManageTargetConnector):
10758
- _pyremote_connector: PyremoteRemoteExecutionConnector
10759
- _bootstrap: MainBootstrap
10858
+ deploy_conf_dir = os.path.join(deploy_dir, 'conf')
10859
+ os.makedirs(deploy_conf_dir, exist_ok=True)
10860
+
10861
+ #
10862
+
10863
+ # def mirror_symlinks(src: str, dst: str) -> None:
10864
+ # def mirror_link(lp: str) -> None:
10865
+ # check.state(os.path.islink(lp))
10866
+ # shutil.copy2(
10867
+ # lp,
10868
+ # os.path.join(dst, os.path.relpath(lp, src)),
10869
+ # follow_symlinks=False,
10870
+ # )
10871
+ #
10872
+ # for dp, dns, fns in os.walk(src, followlinks=False):
10873
+ # for fn in fns:
10874
+ # mirror_link(os.path.join(dp, fn))
10875
+ #
10876
+ # for dn in dns:
10877
+ # dp2 = os.path.join(dp, dn)
10878
+ # if os.path.islink(dp2):
10879
+ # mirror_link(dp2)
10880
+ # else:
10881
+ # os.makedirs(os.path.join(dst, os.path.relpath(dp2, src)))
10882
+
10883
+ current_link = os.path.join(home, 'deploys/current')
10884
+
10885
+ # if os.path.exists(current_link):
10886
+ # mirror_symlinks(
10887
+ # os.path.join(current_link, 'conf'),
10888
+ # conf_tag_dir,
10889
+ # )
10890
+ # mirror_symlinks(
10891
+ # os.path.join(current_link, 'apps'),
10892
+ # os.path.join(deploy_dir, 'apps'),
10893
+ # )
10760
10894
 
10761
- @contextlib.asynccontextmanager
10762
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10763
- dmt = check.isinstance(tgt, DockerManageTarget)
10895
+ #
10764
10896
 
10765
- sh_parts: ta.List[str] = ['docker']
10766
- if dmt.image is not None:
10767
- sh_parts.extend(['run', '-i', dmt.image])
10768
- elif dmt.container_id is not None:
10769
- sh_parts.extend(['exec', '-i', dmt.container_id])
10770
- else:
10771
- raise ValueError(dmt)
10897
+ app_git_dir = os.path.join(app_dir, 'git')
10898
+ await self._git.checkout(
10899
+ spec.git,
10900
+ home,
10901
+ app_git_dir,
10902
+ )
10772
10903
 
10773
- async with self._pyremote_connector.connect(
10774
- RemoteSpawning.Target(
10775
- shell=' '.join(sh_parts),
10776
- python=self._default_python(dmt.python),
10777
- ),
10778
- self._bootstrap,
10779
- ) as rce:
10780
- yield rce
10904
+ #
10781
10905
 
10906
+ if spec.venv is not None:
10907
+ app_venv_dir = os.path.join(app_dir, 'venv')
10908
+ await self._venvs.setup_venv(
10909
+ spec.venv,
10910
+ home,
10911
+ app_git_dir,
10912
+ app_venv_dir,
10913
+ )
10782
10914
 
10783
- ##
10915
+ #
10784
10916
 
10917
+ if spec.conf is not None:
10918
+ app_conf_dir = os.path.join(app_dir, 'conf')
10919
+ await self._conf.write_app_conf(
10920
+ spec.conf,
10921
+ tags,
10922
+ app_conf_dir,
10923
+ deploy_conf_dir,
10924
+ )
10785
10925
 
10786
- @dc.dataclass(frozen=True)
10787
- class SshManageTargetConnector(ManageTargetConnector):
10788
- _pyremote_connector: PyremoteRemoteExecutionConnector
10789
- _bootstrap: MainBootstrap
10926
+ #
10790
10927
 
10791
- @contextlib.asynccontextmanager
10792
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10793
- smt = check.isinstance(tgt, SshManageTarget)
10928
+ os.replace(deploying_link, current_link)
10794
10929
 
10795
- sh_parts: ta.List[str] = ['ssh']
10796
- if smt.key_file is not None:
10797
- sh_parts.extend(['-i', smt.key_file])
10798
- addr = check.not_none(smt.host)
10799
- if smt.username is not None:
10800
- addr = f'{smt.username}@{addr}'
10801
- sh_parts.append(addr)
10802
10930
 
10803
- async with self._pyremote_connector.connect(
10804
- RemoteSpawning.Target(
10805
- shell=' '.join(sh_parts),
10806
- shell_quote=True,
10807
- python=self._default_python(smt.python),
10808
- ),
10809
- self._bootstrap,
10810
- ) as rce:
10811
- yield rce
10931
+ ########################################
10932
+ # ../deploy/deploy.py
10812
10933
 
10813
10934
 
10814
- ########################################
10815
- # ../../../omdev/interp/resolvers.py
10935
+ DEPLOY_TAG_DATETIME_FMT = '%Y%m%dT%H%M%SZ'
10816
10936
 
10817
10937
 
10818
- INTERP_PROVIDER_TYPES_BY_NAME: ta.Mapping[str, ta.Type[InterpProvider]] = {
10819
- cls.name: cls for cls in deep_subclasses(InterpProvider) if abc.ABC not in cls.__bases__ # type: ignore
10820
- }
10938
+ DeployManagerUtcClock = ta.NewType('DeployManagerUtcClock', Func0[datetime.datetime])
10821
10939
 
10822
10940
 
10823
- class InterpResolver:
10941
+ class DeployManager:
10824
10942
  def __init__(
10825
10943
  self,
10826
- providers: ta.Sequence[ta.Tuple[str, InterpProvider]],
10827
- ) -> None:
10944
+ *,
10945
+ apps: DeployAppManager,
10946
+ paths: DeployPathsManager,
10947
+
10948
+ utc_clock: ta.Optional[DeployManagerUtcClock] = None,
10949
+ ):
10828
10950
  super().__init__()
10829
10951
 
10830
- self._providers: ta.Mapping[str, InterpProvider] = collections.OrderedDict(providers)
10952
+ self._apps = apps
10953
+ self._paths = paths
10831
10954
 
10832
- async def _resolve_installed(self, spec: InterpSpecifier) -> ta.Optional[ta.Tuple[InterpProvider, InterpVersion]]:
10833
- lst = [
10834
- (i, si)
10835
- for i, p in enumerate(self._providers.values())
10836
- for si in await p.get_installed_versions(spec)
10837
- if spec.contains(si)
10838
- ]
10955
+ self._utc_clock = utc_clock
10839
10956
 
10840
- slst = sorted(lst, key=lambda t: (-t[0], t[1].version))
10841
- if not slst:
10842
- return None
10957
+ def _utc_now(self) -> datetime.datetime:
10958
+ if self._utc_clock is not None:
10959
+ return self._utc_clock() # noqa
10960
+ else:
10961
+ return datetime.datetime.now(tz=datetime.timezone.utc) # noqa
10843
10962
 
10844
- bi, bv = slst[-1]
10845
- bp = list(self._providers.values())[bi]
10846
- return (bp, bv)
10963
+ def _make_deploy_time(self) -> DeployTime:
10964
+ return DeployTime(self._utc_now().strftime(DEPLOY_TAG_DATETIME_FMT))
10847
10965
 
10848
- async def resolve(
10966
+ async def run_deploy(
10849
10967
  self,
10850
- spec: InterpSpecifier,
10851
- *,
10852
- install: bool = False,
10853
- ) -> ta.Optional[Interp]:
10854
- tup = await self._resolve_installed(spec)
10855
- if tup is not None:
10856
- bp, bv = tup
10857
- return await bp.get_installed_version(bv)
10858
-
10859
- if not install:
10860
- return None
10861
-
10862
- tp = list(self._providers.values())[0] # noqa
10863
-
10864
- sv = sorted(
10865
- [s for s in await tp.get_installable_versions(spec) if s in spec],
10866
- key=lambda s: s.version,
10867
- )
10868
- if not sv:
10869
- return None
10968
+ spec: DeploySpec,
10969
+ ) -> None:
10970
+ self._paths.validate_deploy_paths()
10870
10971
 
10871
- bv = sv[-1]
10872
- return await tp.install_version(bv)
10972
+ #
10873
10973
 
10874
- async def list(self, spec: InterpSpecifier) -> None:
10875
- print('installed:')
10876
- for n, p in self._providers.items():
10877
- lst = [
10878
- si
10879
- for si in await p.get_installed_versions(spec)
10880
- if spec.contains(si)
10881
- ]
10882
- if lst:
10883
- print(f' {n}')
10884
- for si in lst:
10885
- print(f' {si}')
10974
+ hs = check.non_empty_str(spec.home)
10975
+ hs = os.path.expanduser(hs)
10976
+ hs = os.path.realpath(hs)
10977
+ hs = os.path.abspath(hs)
10886
10978
 
10887
- print()
10979
+ home = DeployHome(hs)
10888
10980
 
10889
- print('installable:')
10890
- for n, p in self._providers.items():
10891
- lst = [
10892
- si
10893
- for si in await p.get_installable_versions(spec)
10894
- if spec.contains(si)
10895
- ]
10896
- if lst:
10897
- print(f' {n}')
10898
- for si in lst:
10899
- print(f' {si}')
10981
+ #
10900
10982
 
10983
+ deploy_tags = DeployTagMap(
10984
+ self._make_deploy_time(),
10985
+ spec.key(),
10986
+ )
10901
10987
 
10902
- DEFAULT_INTERP_RESOLVER = InterpResolver([(p.name, p) for p in [
10903
- # pyenv is preferred to system interpreters as it tends to have more support for things like tkinter
10904
- PyenvInterpProvider(try_update=True),
10988
+ #
10905
10989
 
10906
- RunningInterpProvider(),
10990
+ for app in spec.apps:
10991
+ app_tags = deploy_tags.add(
10992
+ app.app,
10993
+ app.key(),
10994
+ DeployAppRev(app.git.rev),
10995
+ )
10907
10996
 
10908
- SystemInterpProvider(),
10909
- ]])
10997
+ await self._apps.prepare_app(
10998
+ app,
10999
+ home,
11000
+ app_tags,
11001
+ )
10910
11002
 
10911
11003
 
10912
11004
  ########################################
@@ -10937,65 +11029,6 @@ class DeployCommandExecutor(CommandExecutor[DeployCommand, DeployCommand.Output]
10937
11029
  return DeployCommand.Output()
10938
11030
 
10939
11031
 
10940
- ########################################
10941
- # ../targets/inject.py
10942
-
10943
-
10944
- def bind_targets() -> InjectorBindings:
10945
- lst: ta.List[InjectorBindingOrBindings] = [
10946
- inj.bind(LocalManageTargetConnector, singleton=True),
10947
- inj.bind(DockerManageTargetConnector, singleton=True),
10948
- inj.bind(SshManageTargetConnector, singleton=True),
10949
-
10950
- inj.bind(TypeSwitchedManageTargetConnector, singleton=True),
10951
- inj.bind(ManageTargetConnector, to_key=TypeSwitchedManageTargetConnector),
10952
- ]
10953
-
10954
- #
10955
-
10956
- def provide_manage_target_connector_map(injector: Injector) -> ManageTargetConnectorMap:
10957
- return ManageTargetConnectorMap({
10958
- LocalManageTarget: injector[LocalManageTargetConnector],
10959
- DockerManageTarget: injector[DockerManageTargetConnector],
10960
- SshManageTarget: injector[SshManageTargetConnector],
10961
- })
10962
- lst.append(inj.bind(provide_manage_target_connector_map, singleton=True))
10963
-
10964
- #
10965
-
10966
- return inj.as_bindings(*lst)
10967
-
10968
-
10969
- ########################################
10970
- # ../deploy/interp.py
10971
-
10972
-
10973
- ##
10974
-
10975
-
10976
- @dc.dataclass(frozen=True)
10977
- class InterpCommand(Command['InterpCommand.Output']):
10978
- spec: str
10979
- install: bool = False
10980
-
10981
- @dc.dataclass(frozen=True)
10982
- class Output(Command.Output):
10983
- exe: str
10984
- version: str
10985
- opts: InterpOpts
10986
-
10987
-
10988
- class InterpCommandExecutor(CommandExecutor[InterpCommand, InterpCommand.Output]):
10989
- async def execute(self, cmd: InterpCommand) -> InterpCommand.Output:
10990
- i = InterpSpecifier.parse(check.not_none(cmd.spec))
10991
- o = check.not_none(await DEFAULT_INTERP_RESOLVER.resolve(i, install=cmd.install))
10992
- return InterpCommand.Output(
10993
- exe=o.exe,
10994
- version=str(o.version.version),
10995
- opts=o.version.opts,
10996
- )
10997
-
10998
-
10999
11032
  ########################################
11000
11033
  # ../deploy/inject.py
11001
11034
 
@@ -11031,13 +11064,18 @@ def bind_deploy(
11031
11064
  bind_manager(DeployManager),
11032
11065
 
11033
11066
  bind_manager(DeployTmpManager),
11034
- inj.bind(AtomicPathSwapping, to_key=DeployTmpManager),
11035
11067
 
11036
11068
  bind_manager(DeployVenvManager),
11037
11069
  ])
11038
11070
 
11039
11071
  #
11040
11072
 
11073
+ def provide_deploy_home_atomics(tmp: DeployTmpManager) -> DeployHomeAtomics:
11074
+ return DeployHomeAtomics(tmp.get_swapping)
11075
+ lst.append(inj.bind(provide_deploy_home_atomics, singleton=True))
11076
+
11077
+ #
11078
+
11041
11079
  lst.extend([
11042
11080
  bind_command(DeployCommand, DeployCommandExecutor),
11043
11081
  bind_command(InterpCommand, InterpCommandExecutor),
@@ -11045,10 +11083,6 @@ def bind_deploy(
11045
11083
 
11046
11084
  #
11047
11085
 
11048
- if (dh := deploy_config.deploy_home) is not None:
11049
- dh = os.path.abspath(os.path.expanduser(dh))
11050
- lst.append(inj.bind(dh, key=DeployHome))
11051
-
11052
11086
  return inj.as_bindings(*lst)
11053
11087
 
11054
11088
 
@@ -11175,10 +11209,9 @@ class MainCli(ArgparseCli):
11175
11209
 
11176
11210
  argparse_arg('--debug', action='store_true'),
11177
11211
 
11178
- argparse_arg('--deploy-home'),
11179
-
11180
11212
  argparse_arg('target'),
11181
- argparse_arg('command', nargs='+'),
11213
+ argparse_arg('-f', '--command-file', action='append'),
11214
+ argparse_arg('command', nargs='*'),
11182
11215
  )
11183
11216
  async def run(self) -> None:
11184
11217
  bs = MainBootstrap(
@@ -11188,9 +11221,7 @@ class MainCli(ArgparseCli):
11188
11221
  debug=bool(self.args.debug),
11189
11222
  ),
11190
11223
 
11191
- deploy_config=DeployConfig(
11192
- deploy_home=self.args.deploy_home,
11193
- ),
11224
+ deploy_config=DeployConfig(),
11194
11225
 
11195
11226
  remote_config=RemoteConfig(
11196
11227
  payload_file=self.args._payload_file, # noqa
@@ -11224,13 +11255,19 @@ class MainCli(ArgparseCli):
11224
11255
  #
11225
11256
 
11226
11257
  cmds: ta.List[Command] = []
11258
+
11227
11259
  cmd: Command
11228
- for c in self.args.command:
11260
+
11261
+ for c in self.args.command or []:
11229
11262
  if not c.startswith('{'):
11230
11263
  c = json.dumps({c: {}})
11231
11264
  cmd = msh.unmarshal_obj(json.loads(c), Command)
11232
11265
  cmds.append(cmd)
11233
11266
 
11267
+ for cf in self.args.command_file or []:
11268
+ cmd = read_config_file(cf, Command, msh=msh)
11269
+ cmds.append(cmd)
11270
+
11234
11271
  #
11235
11272
 
11236
11273
  async with injector[ManageTargetConnector].connect(tgt) as ce: