ominfra 0.0.0.dev154__py3-none-any.whl → 0.0.0.dev156__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.
Files changed (43) hide show
  1. ominfra/manage/bootstrap.py +4 -0
  2. ominfra/manage/bootstrap_.py +5 -0
  3. ominfra/manage/commands/inject.py +8 -11
  4. ominfra/manage/commands/{execution.py → local.py} +1 -5
  5. ominfra/manage/commands/ping.py +23 -0
  6. ominfra/manage/commands/subprocess.py +3 -4
  7. ominfra/manage/commands/types.py +8 -0
  8. ominfra/manage/deploy/apps.py +72 -0
  9. ominfra/manage/deploy/config.py +8 -0
  10. ominfra/manage/deploy/git.py +136 -0
  11. ominfra/manage/deploy/inject.py +21 -0
  12. ominfra/manage/deploy/paths.py +81 -28
  13. ominfra/manage/deploy/types.py +13 -0
  14. ominfra/manage/deploy/venvs.py +66 -0
  15. ominfra/manage/inject.py +20 -4
  16. ominfra/manage/main.py +15 -27
  17. ominfra/manage/remote/_main.py +1 -1
  18. ominfra/manage/remote/config.py +0 -2
  19. ominfra/manage/remote/connection.py +7 -24
  20. ominfra/manage/remote/execution.py +1 -1
  21. ominfra/manage/remote/inject.py +3 -14
  22. ominfra/manage/remote/spawning.py +2 -2
  23. ominfra/manage/system/commands.py +22 -2
  24. ominfra/manage/system/config.py +3 -1
  25. ominfra/manage/system/inject.py +16 -6
  26. ominfra/manage/system/packages.py +38 -14
  27. ominfra/manage/system/platforms.py +72 -0
  28. ominfra/manage/targets/__init__.py +0 -0
  29. ominfra/manage/targets/connection.py +150 -0
  30. ominfra/manage/targets/inject.py +42 -0
  31. ominfra/manage/targets/targets.py +87 -0
  32. ominfra/scripts/journald2aws.py +205 -134
  33. ominfra/scripts/manage.py +2192 -734
  34. ominfra/scripts/supervisor.py +187 -25
  35. ominfra/supervisor/configs.py +163 -18
  36. {ominfra-0.0.0.dev154.dist-info → ominfra-0.0.0.dev156.dist-info}/METADATA +3 -3
  37. {ominfra-0.0.0.dev154.dist-info → ominfra-0.0.0.dev156.dist-info}/RECORD +42 -31
  38. ominfra/manage/system/types.py +0 -5
  39. /ominfra/manage/{commands → deploy}/interp.py +0 -0
  40. {ominfra-0.0.0.dev154.dist-info → ominfra-0.0.0.dev156.dist-info}/LICENSE +0 -0
  41. {ominfra-0.0.0.dev154.dist-info → ominfra-0.0.0.dev156.dist-info}/WHEEL +0 -0
  42. {ominfra-0.0.0.dev154.dist-info → ominfra-0.0.0.dev156.dist-info}/entry_points.txt +0 -0
  43. {ominfra-0.0.0.dev154.dist-info → ominfra-0.0.0.dev156.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,8 @@
1
+ # ruff: noqa: UP006 UP007
1
2
  import dataclasses as dc
2
3
 
3
4
  from .config import MainConfig
5
+ from .deploy.config import DeployConfig
4
6
  from .remote.config import RemoteConfig
5
7
  from .system.config import SystemConfig
6
8
 
@@ -9,6 +11,8 @@ from .system.config import SystemConfig
9
11
  class MainBootstrap:
10
12
  main_config: MainConfig = MainConfig()
11
13
 
14
+ deploy_config: DeployConfig = DeployConfig()
15
+
12
16
  remote_config: RemoteConfig = RemoteConfig()
13
17
 
14
18
  system_config: SystemConfig = SystemConfig()
@@ -1,3 +1,4 @@
1
+ # ruff: noqa: UP006 UP007
1
2
  from omlish.lite.inject import Injector
2
3
  from omlish.lite.inject import inj
3
4
  from omlish.lite.logs import configure_standard_logging
@@ -12,8 +13,12 @@ def main_bootstrap(bs: MainBootstrap) -> Injector:
12
13
 
13
14
  injector = inj.create_injector(bind_main( # noqa
14
15
  main_config=bs.main_config,
16
+
17
+ deploy_config=bs.deploy_config,
15
18
  remote_config=bs.remote_config,
16
19
  system_config=bs.system_config,
20
+
21
+ main_bootstrap=bs,
17
22
  ))
18
23
 
19
24
  return injector
@@ -18,13 +18,13 @@ from .base import CommandNameMap
18
18
  from .base import CommandRegistration
19
19
  from .base import CommandRegistrations
20
20
  from .base import build_command_name_map
21
- from .execution import CommandExecutorMap
22
- from .execution import LocalCommandExecutor
23
- from .interp import InterpCommand
24
- from .interp import InterpCommandExecutor
21
+ from .local import LocalCommandExecutor
25
22
  from .marshal import install_command_marshaling
23
+ from .ping import PingCommand
24
+ from .ping import PingCommandExecutor
26
25
  from .subprocess import SubprocessCommand
27
26
  from .subprocess import SubprocessCommandExecutor
27
+ from .types import CommandExecutorMap
28
28
 
29
29
 
30
30
  ##
@@ -113,13 +113,10 @@ def bind_commands(
113
113
 
114
114
  #
115
115
 
116
- command_cls: ta.Any
117
- executor_cls: ta.Any
118
- for command_cls, executor_cls in [
119
- (SubprocessCommand, SubprocessCommandExecutor),
120
- (InterpCommand, InterpCommandExecutor),
121
- ]:
122
- lst.append(bind_command(command_cls, executor_cls))
116
+ lst.extend([
117
+ bind_command(PingCommand, PingCommandExecutor),
118
+ bind_command(SubprocessCommand, SubprocessCommandExecutor),
119
+ ])
123
120
 
124
121
  #
125
122
 
@@ -1,11 +1,7 @@
1
1
  # ruff: noqa: UP006 UP007
2
- import typing as ta
3
-
4
2
  from .base import Command
5
3
  from .base import CommandExecutor
6
-
7
-
8
- CommandExecutorMap = ta.NewType('CommandExecutorMap', ta.Mapping[ta.Type[Command], CommandExecutor])
4
+ from .types import CommandExecutorMap
9
5
 
10
6
 
11
7
  class LocalCommandExecutor(CommandExecutor):
@@ -0,0 +1,23 @@
1
+ # ruff: noqa: TC003 UP006 UP007
2
+ import dataclasses as dc
3
+ import time
4
+
5
+ from .base import Command
6
+ from .base import CommandExecutor
7
+
8
+
9
+ ##
10
+
11
+
12
+ @dc.dataclass(frozen=True)
13
+ class PingCommand(Command['PingCommand.Output']):
14
+ time: float = dc.field(default_factory=time.time)
15
+
16
+ @dc.dataclass(frozen=True)
17
+ class Output(Command.Output):
18
+ time: float
19
+
20
+
21
+ class PingCommandExecutor(CommandExecutor[PingCommand, PingCommand.Output]):
22
+ async def execute(self, cmd: PingCommand) -> PingCommand.Output:
23
+ return PingCommand.Output(cmd.time)
@@ -6,8 +6,7 @@ import subprocess
6
6
  import time
7
7
  import typing as ta
8
8
 
9
- from omlish.lite.asyncio.subprocesses import asyncio_subprocess_communicate
10
- from omlish.lite.asyncio.subprocesses import asyncio_subprocess_popen
9
+ from omlish.lite.asyncio.subprocesses import asyncio_subprocesses
11
10
  from omlish.lite.check import check
12
11
  from omlish.lite.subprocesses import SUBPROCESS_CHANNEL_OPTION_VALUES
13
12
  from omlish.lite.subprocesses import SubprocessChannelOption
@@ -51,7 +50,7 @@ class SubprocessCommand(Command['SubprocessCommand.Output']):
51
50
  class SubprocessCommandExecutor(CommandExecutor[SubprocessCommand, SubprocessCommand.Output]):
52
51
  async def execute(self, cmd: SubprocessCommand) -> SubprocessCommand.Output:
53
52
  proc: asyncio.subprocess.Process
54
- async with asyncio_subprocess_popen(
53
+ async with asyncio_subprocesses.popen(
55
54
  *subprocess_maybe_shell_wrap_exec(*cmd.cmd),
56
55
 
57
56
  shell=cmd.shell,
@@ -65,7 +64,7 @@ class SubprocessCommandExecutor(CommandExecutor[SubprocessCommand, SubprocessCom
65
64
  timeout=cmd.timeout,
66
65
  ) as proc:
67
66
  start_time = time.time()
68
- stdout, stderr = await asyncio_subprocess_communicate(
67
+ stdout, stderr = await asyncio_subprocesses.communicate(
69
68
  proc,
70
69
  input=cmd.input,
71
70
  timeout=cmd.timeout,
@@ -0,0 +1,8 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import typing as ta
3
+
4
+ from .base import Command
5
+ from .base import CommandExecutor
6
+
7
+
8
+ CommandExecutorMap = ta.NewType('CommandExecutorMap', ta.Mapping[ta.Type[Command], CommandExecutor])
@@ -0,0 +1,72 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import datetime
3
+ import os.path
4
+ import typing as ta
5
+
6
+ from .git import DeployGitManager
7
+ from .git import DeployGitRepo
8
+ from .git import DeployGitSpec
9
+ from .paths import DeployPath
10
+ from .paths import DeployPathOwner
11
+ from .types import DeployApp
12
+ from .types import DeployAppTag
13
+ from .types import DeployHome
14
+ from .types import DeployRev
15
+ from .types import DeployTag
16
+ from .venvs import DeployVenvManager
17
+
18
+
19
+ def make_deploy_tag(
20
+ rev: DeployRev,
21
+ now: ta.Optional[datetime.datetime] = None,
22
+ ) -> DeployTag:
23
+ if now is None:
24
+ now = datetime.datetime.utcnow() # noqa
25
+ now_fmt = '%Y%m%dT%H%M%S'
26
+ now_str = now.strftime(now_fmt)
27
+ return DeployTag('-'.join([rev, now_str]))
28
+
29
+
30
+ class DeployAppManager(DeployPathOwner):
31
+ def __init__(
32
+ self,
33
+ *,
34
+ deploy_home: DeployHome,
35
+ git: DeployGitManager,
36
+ venvs: DeployVenvManager,
37
+ ) -> None:
38
+ super().__init__()
39
+
40
+ self._deploy_home = deploy_home
41
+ self._git = git
42
+ self._venvs = venvs
43
+
44
+ self._dir = os.path.join(deploy_home, 'apps')
45
+
46
+ def get_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
47
+ return {
48
+ DeployPath.parse('apps/@app/@tag'),
49
+ }
50
+
51
+ async def prepare_app(
52
+ self,
53
+ app: DeployApp,
54
+ rev: DeployRev,
55
+ repo: DeployGitRepo,
56
+ ):
57
+ app_tag = DeployAppTag(app, make_deploy_tag(rev))
58
+ app_dir = os.path.join(self._dir, app, app_tag.tag)
59
+
60
+ #
61
+
62
+ await self._git.checkout(
63
+ DeployGitSpec(
64
+ repo=repo,
65
+ rev=rev,
66
+ ),
67
+ app_dir,
68
+ )
69
+
70
+ #
71
+
72
+ await self._venvs.setup_app_venv(app_tag)
@@ -0,0 +1,8 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import dataclasses as dc
3
+ import typing as ta
4
+
5
+
6
+ @dc.dataclass(frozen=True)
7
+ class DeployConfig:
8
+ deploy_home: ta.Optional[str] = None
@@ -0,0 +1,136 @@
1
+ # ruff: noqa: UP006 UP007
2
+ """
3
+ TODO:
4
+ - 'repos'?
5
+
6
+ git/github.com/wrmsr/omlish <- bootstrap repo
7
+ - shallow clone off bootstrap into /apps
8
+
9
+ github.com/wrmsr/omlish@rev
10
+ """
11
+ import dataclasses as dc
12
+ import functools
13
+ import os.path
14
+ import typing as ta
15
+
16
+ from omlish.lite.asyncio.subprocesses import asyncio_subprocesses
17
+ from omlish.lite.cached import async_cached_nullary
18
+ from omlish.lite.check import check
19
+
20
+ from .paths import DeployPath
21
+ from .paths import DeployPathOwner
22
+ from .types import DeployHome
23
+ from .types import DeployRev
24
+
25
+
26
+ ##
27
+
28
+
29
+ @dc.dataclass(frozen=True)
30
+ class DeployGitRepo:
31
+ host: ta.Optional[str] = None
32
+ username: ta.Optional[str] = None
33
+ path: ta.Optional[str] = None
34
+
35
+ def __post_init__(self) -> None:
36
+ check.not_in('..', check.non_empty_str(self.host))
37
+ check.not_in('.', check.non_empty_str(self.path))
38
+
39
+
40
+ @dc.dataclass(frozen=True)
41
+ class DeployGitSpec:
42
+ repo: DeployGitRepo
43
+ rev: DeployRev
44
+
45
+
46
+ ##
47
+
48
+
49
+ class DeployGitManager(DeployPathOwner):
50
+ def __init__(
51
+ self,
52
+ *,
53
+ deploy_home: DeployHome,
54
+ ) -> None:
55
+ super().__init__()
56
+
57
+ self._deploy_home = deploy_home
58
+ self._dir = os.path.join(deploy_home, 'git')
59
+
60
+ self._repo_dirs: ta.Dict[DeployGitRepo, DeployGitManager.RepoDir] = {}
61
+
62
+ def get_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
63
+ return {
64
+ DeployPath.parse('git'),
65
+ }
66
+
67
+ class RepoDir:
68
+ def __init__(
69
+ self,
70
+ git: 'DeployGitManager',
71
+ repo: DeployGitRepo,
72
+ ) -> None:
73
+ super().__init__()
74
+
75
+ self._git = git
76
+ self._repo = repo
77
+ self._dir = os.path.join(
78
+ self._git._dir, # noqa
79
+ check.non_empty_str(repo.host),
80
+ check.non_empty_str(repo.path),
81
+ )
82
+
83
+ @property
84
+ def repo(self) -> DeployGitRepo:
85
+ return self._repo
86
+
87
+ @property
88
+ def url(self) -> str:
89
+ if self._repo.username is not None:
90
+ return f'{self._repo.username}@{self._repo.host}:{self._repo.path}'
91
+ else:
92
+ return f'https://{self._repo.host}/{self._repo.path}'
93
+
94
+ async def _call(self, *cmd: str) -> None:
95
+ await asyncio_subprocesses.check_call(
96
+ *cmd,
97
+ cwd=self._dir,
98
+ )
99
+
100
+ @async_cached_nullary
101
+ async def init(self) -> None:
102
+ os.makedirs(self._dir, exist_ok=True)
103
+ if os.path.exists(os.path.join(self._dir, '.git')):
104
+ return
105
+
106
+ await self._call('git', 'init')
107
+ await self._call('git', 'remote', 'add', 'origin', self.url)
108
+
109
+ async def fetch(self, rev: DeployRev) -> None:
110
+ await self.init()
111
+ await self._call('git', 'fetch', '--depth=1', 'origin', rev)
112
+
113
+ async def checkout(self, rev: DeployRev, dst_dir: str) -> None:
114
+ check.state(not os.path.exists(dst_dir))
115
+
116
+ await self.fetch(rev)
117
+
118
+ # FIXME: temp dir swap
119
+ os.makedirs(dst_dir)
120
+
121
+ dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_dir)
122
+ await dst_call('git', 'init')
123
+
124
+ await dst_call('git', 'remote', 'add', 'local', self._dir)
125
+ await dst_call('git', 'fetch', '--depth=1', 'local', rev)
126
+ await dst_call('git', 'checkout', rev)
127
+
128
+ def get_repo_dir(self, repo: DeployGitRepo) -> RepoDir:
129
+ try:
130
+ return self._repo_dirs[repo]
131
+ except KeyError:
132
+ repo_dir = self._repo_dirs[repo] = DeployGitManager.RepoDir(self, repo)
133
+ return repo_dir
134
+
135
+ async def checkout(self, spec: DeployGitSpec, dst_dir: str) -> None:
136
+ await self.get_repo_dir(spec.repo).checkout(spec.rev, dst_dir)
@@ -1,4 +1,5 @@
1
1
  # ruff: noqa: UP006 UP007
2
+ import os.path
2
3
  import typing as ta
3
4
 
4
5
  from omlish.lite.inject import InjectorBindingOrBindings
@@ -6,14 +7,34 @@ from omlish.lite.inject import InjectorBindings
6
7
  from omlish.lite.inject import inj
7
8
 
8
9
  from ..commands.inject import bind_command
10
+ from .apps import DeployAppManager
9
11
  from .commands import DeployCommand
10
12
  from .commands import DeployCommandExecutor
13
+ from .config import DeployConfig
14
+ from .git import DeployGitManager
15
+ from .interp import InterpCommand
16
+ from .interp import InterpCommandExecutor
17
+ from .types import DeployHome
18
+ from .venvs import DeployVenvManager
11
19
 
12
20
 
13
21
  def bind_deploy(
22
+ *,
23
+ deploy_config: DeployConfig,
14
24
  ) -> InjectorBindings:
15
25
  lst: ta.List[InjectorBindingOrBindings] = [
26
+ inj.bind(deploy_config),
27
+
28
+ inj.bind(DeployAppManager, singleton=True),
29
+ inj.bind(DeployGitManager, singleton=True),
30
+ inj.bind(DeployVenvManager, singleton=True),
31
+
16
32
  bind_command(DeployCommand, DeployCommandExecutor),
33
+ bind_command(InterpCommand, InterpCommandExecutor),
17
34
  ]
18
35
 
36
+ if (dh := deploy_config.deploy_home) is not None:
37
+ dh = os.path.abspath(os.path.expanduser(dh))
38
+ lst.append(inj.bind(dh, key=DeployHome))
39
+
19
40
  return inj.as_bindings(*lst)
@@ -30,6 +30,9 @@ for dn in [
30
30
  'conf/supervisor',
31
31
  'venv',
32
32
  ]:
33
+
34
+ ==
35
+
33
36
  """
34
37
  import abc
35
38
  import dataclasses as dc
@@ -40,12 +43,23 @@ from omlish.lite.check import check
40
43
 
41
44
 
42
45
  DeployPathKind = ta.Literal['dir', 'file'] # ta.TypeAlias
46
+ DeployPathSpec = ta.Literal['app', 'tag'] # ta.TypeAlias
43
47
 
44
48
 
45
49
  ##
46
50
 
47
51
 
48
52
  DEPLOY_PATH_SPEC_PLACEHOLDER = '@'
53
+ DEPLOY_PATH_SPEC_SEPARATORS = '-.'
54
+
55
+ DEPLOY_PATH_SPECS: ta.FrozenSet[str] = frozenset([
56
+ 'app',
57
+ 'tag', # <rev>-<dt>
58
+ ])
59
+
60
+
61
+ class DeployPathError(Exception):
62
+ pass
49
63
 
50
64
 
51
65
  @dc.dataclass(frozen=True)
@@ -56,39 +70,43 @@ class DeployPathPart(abc.ABC): # noqa
56
70
  raise NotImplementedError
57
71
 
58
72
  @abc.abstractmethod
59
- def render(self) -> str:
73
+ def render(self, specs: ta.Optional[ta.Mapping[DeployPathSpec, str]] = None) -> str:
60
74
  raise NotImplementedError
61
75
 
62
76
 
63
77
  #
64
78
 
65
79
 
66
- class DeployPathDir(DeployPathPart, abc.ABC):
80
+ class DirDeployPathPart(DeployPathPart, abc.ABC):
67
81
  @property
68
82
  def kind(self) -> DeployPathKind:
69
83
  return 'dir'
70
84
 
71
85
  @classmethod
72
- def parse(cls, s: str) -> 'DeployPathDir':
86
+ def parse(cls, s: str) -> 'DirDeployPathPart':
73
87
  if DEPLOY_PATH_SPEC_PLACEHOLDER in s:
74
- check.equal(s, DEPLOY_PATH_SPEC_PLACEHOLDER)
75
- return SpecDeployPathDir()
88
+ check.equal(s[0], DEPLOY_PATH_SPEC_PLACEHOLDER)
89
+ return SpecDirDeployPathPart(s[1:])
76
90
  else:
77
- return ConstDeployPathDir(s)
91
+ return ConstDirDeployPathPart(s)
78
92
 
79
93
 
80
- class DeployPathFile(DeployPathPart, abc.ABC):
94
+ class FileDeployPathPart(DeployPathPart, abc.ABC):
81
95
  @property
82
96
  def kind(self) -> DeployPathKind:
83
97
  return 'file'
84
98
 
85
99
  @classmethod
86
- def parse(cls, s: str) -> 'DeployPathFile':
100
+ def parse(cls, s: str) -> 'FileDeployPathPart':
87
101
  if DEPLOY_PATH_SPEC_PLACEHOLDER in s:
88
102
  check.equal(s[0], DEPLOY_PATH_SPEC_PLACEHOLDER)
89
- return SpecDeployPathFile(s[1:])
103
+ if not any(c in s for c in DEPLOY_PATH_SPEC_SEPARATORS):
104
+ return SpecFileDeployPathPart(s[1:], '')
105
+ else:
106
+ p = min(f for c in DEPLOY_PATH_SPEC_SEPARATORS if (f := s.find(c)) > 0)
107
+ return SpecFileDeployPathPart(s[1:p], s[p:])
90
108
  else:
91
- return ConstDeployPathFile(s)
109
+ return ConstFileDeployPathPart(s)
92
110
 
93
111
 
94
112
  #
@@ -103,41 +121,56 @@ class ConstDeployPathPart(DeployPathPart, abc.ABC):
103
121
  check.not_in('/', self.name)
104
122
  check.not_in(DEPLOY_PATH_SPEC_PLACEHOLDER, self.name)
105
123
 
106
- def render(self) -> str:
124
+ def render(self, specs: ta.Optional[ta.Mapping[DeployPathSpec, str]] = None) -> str:
107
125
  return self.name
108
126
 
109
127
 
110
- class ConstDeployPathDir(ConstDeployPathPart, DeployPathDir):
128
+ class ConstDirDeployPathPart(ConstDeployPathPart, DirDeployPathPart):
111
129
  pass
112
130
 
113
131
 
114
- class ConstDeployPathFile(ConstDeployPathPart, DeployPathFile):
132
+ class ConstFileDeployPathPart(ConstDeployPathPart, FileDeployPathPart):
115
133
  pass
116
134
 
117
135
 
118
136
  #
119
137
 
120
138
 
139
+ @dc.dataclass(frozen=True)
121
140
  class SpecDeployPathPart(DeployPathPart, abc.ABC):
122
- pass
141
+ spec: str # DeployPathSpec
142
+
143
+ def __post_init__(self) -> None:
144
+ check.non_empty_str(self.spec)
145
+ for c in [*DEPLOY_PATH_SPEC_SEPARATORS, DEPLOY_PATH_SPEC_PLACEHOLDER, '/']:
146
+ check.not_in(c, self.spec)
147
+ check.in_(self.spec, DEPLOY_PATH_SPECS)
148
+
149
+ def _render_spec(self, specs: ta.Optional[ta.Mapping[DeployPathSpec, str]] = None) -> str:
150
+ if specs is not None:
151
+ return specs[self.spec] # type: ignore
152
+ else:
153
+ return DEPLOY_PATH_SPEC_PLACEHOLDER + self.spec
123
154
 
124
155
 
125
- class SpecDeployPathDir(SpecDeployPathPart, DeployPathDir):
126
- def render(self) -> str:
127
- return DEPLOY_PATH_SPEC_PLACEHOLDER
156
+ @dc.dataclass(frozen=True)
157
+ class SpecDirDeployPathPart(SpecDeployPathPart, DirDeployPathPart):
158
+ def render(self, specs: ta.Optional[ta.Mapping[DeployPathSpec, str]] = None) -> str:
159
+ return self._render_spec(specs)
128
160
 
129
161
 
130
162
  @dc.dataclass(frozen=True)
131
- class SpecDeployPathFile(SpecDeployPathPart, DeployPathFile):
163
+ class SpecFileDeployPathPart(SpecDeployPathPart, FileDeployPathPart):
132
164
  suffix: str
133
165
 
134
166
  def __post_init__(self) -> None:
135
- check.non_empty_str(self.suffix)
136
- check.not_in('/', self.suffix)
137
- check.not_in(DEPLOY_PATH_SPEC_PLACEHOLDER, self.suffix)
167
+ super().__post_init__()
168
+ if self.suffix:
169
+ for c in [DEPLOY_PATH_SPEC_PLACEHOLDER, '/']:
170
+ check.not_in(c, self.suffix)
138
171
 
139
- def render(self) -> str:
140
- return DEPLOY_PATH_SPEC_PLACEHOLDER + self.suffix
172
+ def render(self, specs: ta.Optional[ta.Mapping[DeployPathSpec, str]] = None) -> str:
173
+ return self._render_spec(specs) + self.suffix
141
174
 
142
175
 
143
176
  ##
@@ -152,13 +185,24 @@ class DeployPath:
152
185
  for p in self.parts[:-1]:
153
186
  check.equal(p.kind, 'dir')
154
187
 
188
+ pd = {}
189
+ for i, p in enumerate(self.parts):
190
+ if isinstance(p, SpecDeployPathPart):
191
+ if p.spec in pd:
192
+ raise DeployPathError('Duplicate specs in path', self)
193
+ pd[p.spec] = i
194
+
195
+ if 'tag' in pd:
196
+ if 'app' not in pd or pd['app'] >= pd['tag']:
197
+ raise DeployPathError('Tag spec in path without preceding app', self)
198
+
155
199
  @property
156
200
  def kind(self) -> ta.Literal['file', 'dir']:
157
201
  return self.parts[-1].kind
158
202
 
159
- def render(self) -> str:
203
+ def render(self, specs: ta.Optional[ta.Mapping[DeployPathSpec, str]] = None) -> str:
160
204
  return os.path.join( # noqa
161
- *[p.render() for p in self.parts],
205
+ *[p.render(specs) for p in self.parts],
162
206
  *([''] if self.kind == 'dir' else []),
163
207
  )
164
208
 
@@ -166,12 +210,21 @@ class DeployPath:
166
210
  def parse(cls, s: str) -> 'DeployPath':
167
211
  tail_parse: ta.Callable[[str], DeployPathPart]
168
212
  if s.endswith('/'):
169
- tail_parse = DeployPathDir.parse
213
+ tail_parse = DirDeployPathPart.parse
170
214
  s = s[:-1]
171
215
  else:
172
- tail_parse = DeployPathFile.parse
216
+ tail_parse = FileDeployPathPart.parse
173
217
  ps = check.non_empty_str(s).split('/')
174
218
  return cls([
175
- *([DeployPathDir.parse(p) for p in ps[:-1]] if len(ps) > 1 else []),
219
+ *([DirDeployPathPart.parse(p) for p in ps[:-1]] if len(ps) > 1 else []),
176
220
  tail_parse(ps[-1]),
177
221
  ])
222
+
223
+
224
+ ##
225
+
226
+
227
+ class DeployPathOwner(abc.ABC):
228
+ @abc.abstractmethod
229
+ def get_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
230
+ raise NotImplementedError
@@ -0,0 +1,13 @@
1
+ import typing as ta
2
+
3
+
4
+ DeployHome = ta.NewType('DeployHome', str)
5
+
6
+ DeployApp = ta.NewType('DeployApp', str)
7
+ DeployTag = ta.NewType('DeployTag', str)
8
+ DeployRev = ta.NewType('DeployRev', str)
9
+
10
+
11
+ class DeployAppTag(ta.NamedTuple):
12
+ app: DeployApp
13
+ tag: DeployTag