ominfra 0.0.0.dev166__py3-none-any.whl → 0.0.0.dev168__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/manage/deploy/apps.py +112 -11
- ominfra/manage/deploy/commands.py +3 -3
- ominfra/manage/deploy/conf.py +183 -0
- ominfra/manage/deploy/deploy.py +27 -0
- ominfra/manage/deploy/git.py +12 -8
- ominfra/manage/deploy/inject.py +32 -7
- ominfra/manage/deploy/paths/__init__.py +0 -0
- ominfra/manage/deploy/paths/inject.py +21 -0
- ominfra/manage/deploy/paths/manager.py +36 -0
- ominfra/manage/deploy/paths/owners.py +50 -0
- ominfra/manage/deploy/paths/paths.py +216 -0
- ominfra/manage/deploy/specs.py +71 -6
- ominfra/manage/deploy/tmp.py +1 -1
- ominfra/manage/deploy/types.py +26 -1
- ominfra/manage/deploy/venvs.py +4 -33
- ominfra/scripts/journald2aws.py +33 -0
- ominfra/scripts/manage.py +1038 -518
- ominfra/scripts/supervisor.py +34 -0
- ominfra/supervisor/configs.py +1 -0
- {ominfra-0.0.0.dev166.dist-info → ominfra-0.0.0.dev168.dist-info}/METADATA +3 -3
- {ominfra-0.0.0.dev166.dist-info → ominfra-0.0.0.dev168.dist-info}/RECORD +25 -19
- ominfra/manage/deploy/paths.py +0 -239
- {ominfra-0.0.0.dev166.dist-info → ominfra-0.0.0.dev168.dist-info}/LICENSE +0 -0
- {ominfra-0.0.0.dev166.dist-info → ominfra-0.0.0.dev168.dist-info}/WHEEL +0 -0
- {ominfra-0.0.0.dev166.dist-info → ominfra-0.0.0.dev168.dist-info}/entry_points.txt +0 -0
- {ominfra-0.0.0.dev166.dist-info → ominfra-0.0.0.dev168.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,216 @@
|
|
1
|
+
# ruff: noqa: UP006 UP007
|
2
|
+
"""
|
3
|
+
TODO:
|
4
|
+
- run/{.pid,.sock}
|
5
|
+
- logs/...
|
6
|
+
- current symlink
|
7
|
+
- conf/{nginx,supervisor}
|
8
|
+
- env/?
|
9
|
+
- apps/<app>/shared
|
10
|
+
"""
|
11
|
+
import abc
|
12
|
+
import dataclasses as dc
|
13
|
+
import itertools
|
14
|
+
import typing as ta
|
15
|
+
|
16
|
+
from omlish.lite.check import check
|
17
|
+
from omlish.lite.strings import split_keep_delimiter
|
18
|
+
|
19
|
+
from ..types import DeployPathKind
|
20
|
+
from ..types import DeployPathPlaceholder
|
21
|
+
|
22
|
+
|
23
|
+
##
|
24
|
+
|
25
|
+
|
26
|
+
DEPLOY_PATH_PLACEHOLDER_SIGIL = '@'
|
27
|
+
DEPLOY_PATH_PLACEHOLDER_SEPARATOR = '--'
|
28
|
+
|
29
|
+
DEPLOY_PATH_PLACEHOLDER_DELIMITERS: ta.AbstractSet[str] = frozenset([
|
30
|
+
DEPLOY_PATH_PLACEHOLDER_SEPARATOR,
|
31
|
+
'.',
|
32
|
+
])
|
33
|
+
|
34
|
+
DEPLOY_PATH_PLACEHOLDERS: ta.FrozenSet[str] = frozenset([
|
35
|
+
'app',
|
36
|
+
'tag',
|
37
|
+
'conf',
|
38
|
+
])
|
39
|
+
|
40
|
+
|
41
|
+
class DeployPathError(Exception):
|
42
|
+
pass
|
43
|
+
|
44
|
+
|
45
|
+
class DeployPathRenderable(abc.ABC):
|
46
|
+
@abc.abstractmethod
|
47
|
+
def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
|
48
|
+
raise NotImplementedError
|
49
|
+
|
50
|
+
|
51
|
+
##
|
52
|
+
|
53
|
+
|
54
|
+
class DeployPathNamePart(DeployPathRenderable, abc.ABC):
|
55
|
+
@classmethod
|
56
|
+
def parse(cls, s: str) -> 'DeployPathNamePart':
|
57
|
+
check.non_empty_str(s)
|
58
|
+
if s.startswith(DEPLOY_PATH_PLACEHOLDER_SIGIL):
|
59
|
+
return PlaceholderDeployPathNamePart(s[1:])
|
60
|
+
elif s in DEPLOY_PATH_PLACEHOLDER_DELIMITERS:
|
61
|
+
return DelimiterDeployPathNamePart(s)
|
62
|
+
else:
|
63
|
+
return ConstDeployPathNamePart(s)
|
64
|
+
|
65
|
+
|
66
|
+
@dc.dataclass(frozen=True)
|
67
|
+
class PlaceholderDeployPathNamePart(DeployPathNamePart):
|
68
|
+
placeholder: str # DeployPathPlaceholder
|
69
|
+
|
70
|
+
def __post_init__(self) -> None:
|
71
|
+
check.in_(self.placeholder, DEPLOY_PATH_PLACEHOLDERS)
|
72
|
+
|
73
|
+
def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
|
74
|
+
if placeholders is not None:
|
75
|
+
return placeholders[self.placeholder] # type: ignore
|
76
|
+
else:
|
77
|
+
return DEPLOY_PATH_PLACEHOLDER_SIGIL + self.placeholder
|
78
|
+
|
79
|
+
|
80
|
+
@dc.dataclass(frozen=True)
|
81
|
+
class DelimiterDeployPathNamePart(DeployPathNamePart):
|
82
|
+
delimiter: str
|
83
|
+
|
84
|
+
def __post_init__(self) -> None:
|
85
|
+
check.in_(self.delimiter, DEPLOY_PATH_PLACEHOLDER_DELIMITERS)
|
86
|
+
|
87
|
+
def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
|
88
|
+
return self.delimiter
|
89
|
+
|
90
|
+
|
91
|
+
@dc.dataclass(frozen=True)
|
92
|
+
class ConstDeployPathNamePart(DeployPathNamePart):
|
93
|
+
const: str
|
94
|
+
|
95
|
+
def __post_init__(self) -> None:
|
96
|
+
check.non_empty_str(self.const)
|
97
|
+
for c in [*DEPLOY_PATH_PLACEHOLDER_DELIMITERS, DEPLOY_PATH_PLACEHOLDER_SIGIL, '/']:
|
98
|
+
check.not_in(c, self.const)
|
99
|
+
|
100
|
+
def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
|
101
|
+
return self.const
|
102
|
+
|
103
|
+
|
104
|
+
@dc.dataclass(frozen=True)
|
105
|
+
class DeployPathName(DeployPathRenderable):
|
106
|
+
parts: ta.Sequence[DeployPathNamePart]
|
107
|
+
|
108
|
+
def __post_init__(self) -> None:
|
109
|
+
hash(self)
|
110
|
+
check.not_empty(self.parts)
|
111
|
+
for k, g in itertools.groupby(self.parts, type):
|
112
|
+
if len(gl := list(g)) > 1:
|
113
|
+
raise DeployPathError(f'May not have consecutive path name part types: {k} {gl}')
|
114
|
+
|
115
|
+
def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
|
116
|
+
return ''.join(p.render(placeholders) for p in self.parts)
|
117
|
+
|
118
|
+
@classmethod
|
119
|
+
def parse(cls, s: str) -> 'DeployPathName':
|
120
|
+
check.non_empty_str(s)
|
121
|
+
check.not_in('/', s)
|
122
|
+
|
123
|
+
i = 0
|
124
|
+
ps = []
|
125
|
+
while i < len(s):
|
126
|
+
ns = [(n, d) for d in DEPLOY_PATH_PLACEHOLDER_DELIMITERS if (n := s.find(d, i)) >= 0]
|
127
|
+
if not ns:
|
128
|
+
ps.append(s[i:])
|
129
|
+
break
|
130
|
+
n, d = min(ns)
|
131
|
+
ps.append(check.non_empty_str(s[i:n]))
|
132
|
+
ps.append(s[n:n + len(d)])
|
133
|
+
i = n + len(d)
|
134
|
+
|
135
|
+
return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
|
136
|
+
|
137
|
+
|
138
|
+
##
|
139
|
+
|
140
|
+
|
141
|
+
@dc.dataclass(frozen=True)
|
142
|
+
class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
|
143
|
+
name: DeployPathName
|
144
|
+
|
145
|
+
@property
|
146
|
+
@abc.abstractmethod
|
147
|
+
def kind(self) -> DeployPathKind:
|
148
|
+
raise NotImplementedError
|
149
|
+
|
150
|
+
def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
|
151
|
+
return self.name.render(placeholders) + ('/' if self.kind == 'dir' else '')
|
152
|
+
|
153
|
+
@classmethod
|
154
|
+
def parse(cls, s: str) -> 'DeployPathPart':
|
155
|
+
if (is_dir := s.endswith('/')):
|
156
|
+
s = s[:-1]
|
157
|
+
check.non_empty_str(s)
|
158
|
+
check.not_in('/', s)
|
159
|
+
|
160
|
+
n = DeployPathName.parse(s)
|
161
|
+
if is_dir:
|
162
|
+
return DirDeployPathPart(n)
|
163
|
+
else:
|
164
|
+
return FileDeployPathPart(n)
|
165
|
+
|
166
|
+
|
167
|
+
class DirDeployPathPart(DeployPathPart):
|
168
|
+
@property
|
169
|
+
def kind(self) -> DeployPathKind:
|
170
|
+
return 'dir'
|
171
|
+
|
172
|
+
|
173
|
+
class FileDeployPathPart(DeployPathPart):
|
174
|
+
@property
|
175
|
+
def kind(self) -> DeployPathKind:
|
176
|
+
return 'file'
|
177
|
+
|
178
|
+
|
179
|
+
#
|
180
|
+
|
181
|
+
|
182
|
+
@dc.dataclass(frozen=True)
|
183
|
+
class DeployPath:
|
184
|
+
parts: ta.Sequence[DeployPathPart]
|
185
|
+
|
186
|
+
@property
|
187
|
+
def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
|
188
|
+
for p in self.parts:
|
189
|
+
yield from p.name.parts
|
190
|
+
|
191
|
+
def __post_init__(self) -> None:
|
192
|
+
hash(self)
|
193
|
+
check.not_empty(self.parts)
|
194
|
+
for p in self.parts[:-1]:
|
195
|
+
check.equal(p.kind, 'dir')
|
196
|
+
|
197
|
+
pd: ta.Dict[DeployPathPlaceholder, ta.List[int]] = {}
|
198
|
+
for i, np in enumerate(self.name_parts):
|
199
|
+
if isinstance(np, PlaceholderDeployPathNamePart):
|
200
|
+
pd.setdefault(ta.cast(DeployPathPlaceholder, np.placeholder), []).append(i)
|
201
|
+
|
202
|
+
# if 'tag' in pd and 'app' not in pd:
|
203
|
+
# raise DeployPathError('Tag placeholder in path without app', self)
|
204
|
+
|
205
|
+
@property
|
206
|
+
def kind(self) -> ta.Literal['file', 'dir']:
|
207
|
+
return self.parts[-1].kind
|
208
|
+
|
209
|
+
def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
|
210
|
+
return ''.join([p.render(placeholders) for p in self.parts])
|
211
|
+
|
212
|
+
@classmethod
|
213
|
+
def parse(cls, s: str) -> 'DeployPath':
|
214
|
+
check.non_empty_str(s)
|
215
|
+
ps = split_keep_delimiter(s, '/')
|
216
|
+
return cls(tuple(DeployPathPart.parse(p) for p in ps))
|
ominfra/manage/deploy/specs.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
# ruff: noqa: UP006 UP007
|
2
|
+
import abc
|
2
3
|
import dataclasses as dc
|
3
4
|
import hashlib
|
4
5
|
import typing as ta
|
@@ -14,6 +15,17 @@ from .types import DeployRev
|
|
14
15
|
##
|
15
16
|
|
16
17
|
|
18
|
+
def check_valid_deploy_spec_path(s: str) -> str:
|
19
|
+
check.non_empty_str(s)
|
20
|
+
for c in ['..', '//']:
|
21
|
+
check.not_in(c, s)
|
22
|
+
check.arg(not s.startswith('/'))
|
23
|
+
return s
|
24
|
+
|
25
|
+
|
26
|
+
##
|
27
|
+
|
28
|
+
|
17
29
|
@dc.dataclass(frozen=True)
|
18
30
|
class DeployGitRepo:
|
19
31
|
host: ta.Optional[str] = None
|
@@ -26,14 +38,13 @@ class DeployGitRepo:
|
|
26
38
|
|
27
39
|
|
28
40
|
@dc.dataclass(frozen=True)
|
29
|
-
class
|
41
|
+
class DeployGitSpec:
|
30
42
|
repo: DeployGitRepo
|
31
43
|
rev: DeployRev
|
32
44
|
|
33
45
|
subtrees: ta.Optional[ta.Sequence[str]] = None
|
34
46
|
|
35
47
|
def __post_init__(self) -> None:
|
36
|
-
hash(self)
|
37
48
|
check.non_empty_str(self.rev)
|
38
49
|
if self.subtrees is not None:
|
39
50
|
for st in self.subtrees:
|
@@ -52,8 +63,62 @@ class DeployVenvSpec:
|
|
52
63
|
|
53
64
|
use_uv: bool = False
|
54
65
|
|
66
|
+
|
67
|
+
##
|
68
|
+
|
69
|
+
|
70
|
+
@dc.dataclass(frozen=True)
|
71
|
+
class DeployConfFile:
|
72
|
+
path: str
|
73
|
+
body: str
|
74
|
+
|
55
75
|
def __post_init__(self) -> None:
|
56
|
-
|
76
|
+
check_valid_deploy_spec_path(self.path)
|
77
|
+
|
78
|
+
|
79
|
+
#
|
80
|
+
|
81
|
+
|
82
|
+
@dc.dataclass(frozen=True)
|
83
|
+
class DeployConfLink(abc.ABC): # noqa
|
84
|
+
"""
|
85
|
+
May be either:
|
86
|
+
- @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
|
87
|
+
- @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
|
88
|
+
- @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
|
89
|
+
"""
|
90
|
+
|
91
|
+
src: str
|
92
|
+
|
93
|
+
def __post_init__(self) -> None:
|
94
|
+
check_valid_deploy_spec_path(self.src)
|
95
|
+
if '/' in self.src:
|
96
|
+
check.equal(self.src.count('/'), 1)
|
97
|
+
|
98
|
+
|
99
|
+
class AppDeployConfLink(DeployConfLink):
|
100
|
+
pass
|
101
|
+
|
102
|
+
|
103
|
+
class TagDeployConfLink(DeployConfLink):
|
104
|
+
pass
|
105
|
+
|
106
|
+
|
107
|
+
#
|
108
|
+
|
109
|
+
|
110
|
+
@dc.dataclass(frozen=True)
|
111
|
+
class DeployConfSpec:
|
112
|
+
files: ta.Optional[ta.Sequence[DeployConfFile]] = None
|
113
|
+
|
114
|
+
links: ta.Optional[ta.Sequence[DeployConfLink]] = None
|
115
|
+
|
116
|
+
def __post_init__(self) -> None:
|
117
|
+
if self.files:
|
118
|
+
seen: ta.Set[str] = set()
|
119
|
+
for f in self.files:
|
120
|
+
check.not_in(f.path, seen)
|
121
|
+
seen.add(f.path)
|
57
122
|
|
58
123
|
|
59
124
|
##
|
@@ -62,12 +127,12 @@ class DeployVenvSpec:
|
|
62
127
|
@dc.dataclass(frozen=True)
|
63
128
|
class DeploySpec:
|
64
129
|
app: DeployApp
|
65
|
-
|
130
|
+
|
131
|
+
git: DeployGitSpec
|
66
132
|
|
67
133
|
venv: ta.Optional[DeployVenvSpec] = None
|
68
134
|
|
69
|
-
|
70
|
-
hash(self)
|
135
|
+
conf: ta.Optional[DeployConfSpec] = None
|
71
136
|
|
72
137
|
@cached_nullary
|
73
138
|
def key(self) -> DeployKey:
|
ominfra/manage/deploy/tmp.py
CHANGED
@@ -8,7 +8,7 @@ from omlish.os.atomics import AtomicPathSwapKind
|
|
8
8
|
from omlish.os.atomics import AtomicPathSwapping
|
9
9
|
from omlish.os.atomics import TempDirAtomicPathSwapping
|
10
10
|
|
11
|
-
from .paths import SingleDirDeployPathOwner
|
11
|
+
from .paths.owners import SingleDirDeployPathOwner
|
12
12
|
from .types import DeployHome
|
13
13
|
|
14
14
|
|
ominfra/manage/deploy/types.py
CHANGED
@@ -1,5 +1,15 @@
|
|
1
|
+
import dataclasses as dc
|
1
2
|
import typing as ta
|
2
3
|
|
4
|
+
from omlish.lite.check import check
|
5
|
+
|
6
|
+
|
7
|
+
DeployPathKind = ta.Literal['dir', 'file'] # ta.TypeAlias
|
8
|
+
DeployPathPlaceholder = ta.Literal['app', 'tag', 'conf'] # ta.TypeAlias
|
9
|
+
|
10
|
+
|
11
|
+
##
|
12
|
+
|
3
13
|
|
4
14
|
DeployHome = ta.NewType('DeployHome', str)
|
5
15
|
|
@@ -9,6 +19,21 @@ DeployRev = ta.NewType('DeployRev', str)
|
|
9
19
|
DeployKey = ta.NewType('DeployKey', str)
|
10
20
|
|
11
21
|
|
12
|
-
|
22
|
+
##
|
23
|
+
|
24
|
+
|
25
|
+
@dc.dataclass(frozen=True)
|
26
|
+
class DeployAppTag:
|
13
27
|
app: DeployApp
|
14
28
|
tag: DeployTag
|
29
|
+
|
30
|
+
def __post_init__(self) -> None:
|
31
|
+
for s in [self.app, self.tag]:
|
32
|
+
check.non_empty_str(s)
|
33
|
+
check.equal(s, s.strip())
|
34
|
+
|
35
|
+
def placeholders(self) -> ta.Mapping[DeployPathPlaceholder, str]:
|
36
|
+
return {
|
37
|
+
'app': self.app,
|
38
|
+
'tag': self.tag,
|
39
|
+
}
|
ominfra/manage/deploy/venvs.py
CHANGED
@@ -5,46 +5,28 @@ TODO:
|
|
5
5
|
- share more code with pyproject?
|
6
6
|
"""
|
7
7
|
import os.path
|
8
|
-
import typing as ta
|
9
8
|
|
10
9
|
from omlish.asyncs.asyncio.subprocesses import asyncio_subprocesses
|
11
|
-
from omlish.lite.cached import cached_nullary
|
12
|
-
from omlish.lite.check import check
|
13
10
|
from omlish.os.atomics import AtomicPathSwapping
|
14
11
|
|
15
|
-
from .paths import DeployPath
|
16
|
-
from .paths import DeployPathOwner
|
17
12
|
from .specs import DeployVenvSpec
|
18
|
-
from .types import DeployAppTag
|
19
|
-
from .types import DeployHome
|
20
13
|
|
21
14
|
|
22
|
-
class DeployVenvManager
|
15
|
+
class DeployVenvManager:
|
23
16
|
def __init__(
|
24
17
|
self,
|
25
18
|
*,
|
26
|
-
deploy_home: ta.Optional[DeployHome] = None,
|
27
19
|
atomics: AtomicPathSwapping,
|
28
20
|
) -> None:
|
29
21
|
super().__init__()
|
30
22
|
|
31
|
-
self._deploy_home = deploy_home
|
32
23
|
self._atomics = atomics
|
33
24
|
|
34
|
-
@cached_nullary
|
35
|
-
def _dir(self) -> str:
|
36
|
-
return os.path.join(check.non_empty_str(self._deploy_home), 'venvs')
|
37
|
-
|
38
|
-
def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
|
39
|
-
return {
|
40
|
-
DeployPath.parse('venvs/@app/@tag/'),
|
41
|
-
}
|
42
|
-
|
43
25
|
async def setup_venv(
|
44
26
|
self,
|
45
|
-
app_dir: str,
|
46
|
-
venv_dir: str,
|
47
27
|
spec: DeployVenvSpec,
|
28
|
+
git_dir: str,
|
29
|
+
venv_dir: str,
|
48
30
|
) -> None:
|
49
31
|
sys_exe = 'python3'
|
50
32
|
|
@@ -58,7 +40,7 @@ class DeployVenvManager(DeployPathOwner):
|
|
58
40
|
|
59
41
|
#
|
60
42
|
|
61
|
-
reqs_txt = os.path.join(
|
43
|
+
reqs_txt = os.path.join(git_dir, 'requirements.txt')
|
62
44
|
|
63
45
|
if os.path.isfile(reqs_txt):
|
64
46
|
if spec.use_uv:
|
@@ -68,14 +50,3 @@ class DeployVenvManager(DeployPathOwner):
|
|
68
50
|
pip_cmd = ['-m', 'pip']
|
69
51
|
|
70
52
|
await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
|
71
|
-
|
72
|
-
async def setup_app_venv(
|
73
|
-
self,
|
74
|
-
app_tag: DeployAppTag,
|
75
|
-
spec: DeployVenvSpec,
|
76
|
-
) -> None:
|
77
|
-
await self.setup_venv(
|
78
|
-
os.path.join(check.non_empty_str(self._deploy_home), 'apps', app_tag.app, app_tag.tag),
|
79
|
-
os.path.join(self._dir(), app_tag.app, app_tag.tag),
|
80
|
-
spec,
|
81
|
-
)
|
ominfra/scripts/journald2aws.py
CHANGED
@@ -961,6 +961,8 @@ def async_cached_nullary(fn): # ta.Callable[..., T]) -> ta.Callable[..., T]:
|
|
961
961
|
"""
|
962
962
|
TODO:
|
963
963
|
- def maybe(v: lang.Maybe[T])
|
964
|
+
- def not_ ?
|
965
|
+
- ** class @dataclass Raise - user message should be able to be an exception type or instance or factory
|
964
966
|
"""
|
965
967
|
|
966
968
|
|
@@ -1527,6 +1529,37 @@ def snake_case(name: str) -> str:
|
|
1527
1529
|
##
|
1528
1530
|
|
1529
1531
|
|
1532
|
+
def strip_with_newline(s: str) -> str:
|
1533
|
+
if not s:
|
1534
|
+
return ''
|
1535
|
+
return s.strip() + '\n'
|
1536
|
+
|
1537
|
+
|
1538
|
+
@ta.overload
|
1539
|
+
def split_keep_delimiter(s: str, d: str) -> str:
|
1540
|
+
...
|
1541
|
+
|
1542
|
+
|
1543
|
+
@ta.overload
|
1544
|
+
def split_keep_delimiter(s: bytes, d: bytes) -> bytes:
|
1545
|
+
...
|
1546
|
+
|
1547
|
+
|
1548
|
+
def split_keep_delimiter(s, d):
|
1549
|
+
ps = []
|
1550
|
+
i = 0
|
1551
|
+
while i < len(s):
|
1552
|
+
if (n := s.find(d, i)) < i:
|
1553
|
+
ps.append(s[i:])
|
1554
|
+
break
|
1555
|
+
ps.append(s[i:n + 1])
|
1556
|
+
i = n + 1
|
1557
|
+
return ps
|
1558
|
+
|
1559
|
+
|
1560
|
+
##
|
1561
|
+
|
1562
|
+
|
1530
1563
|
def is_dunder(name: str) -> bool:
|
1531
1564
|
return (
|
1532
1565
|
name[:2] == name[-2:] == '__' and
|