ae-managed-files 0.3.2__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.
- ae/managed_files.py +532 -0
- ae_managed_files-0.3.2.dist-info/METADATA +142 -0
- ae_managed_files-0.3.2.dist-info/RECORD +7 -0
- ae_managed_files-0.3.2.dist-info/WHEEL +5 -0
- ae_managed_files-0.3.2.dist-info/licenses/LICENSE.md +676 -0
- ae_managed_files-0.3.2.dist-info/top_level.txt +1 -0
- ae_managed_files-0.3.2.dist-info/zip-safe +1 -0
ae/managed_files.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
managed files
|
|
3
|
+
=============
|
|
4
|
+
|
|
5
|
+
this portion of the ``ae`` namespace creates files from templates, to maintain and keep similar files up-to-date.
|
|
6
|
+
files that are mostly identical, like e.g. the license or contribution info of your software projects, can
|
|
7
|
+
automatically be checked and renewed. variations in the file content, like the name and version of a concrete project,
|
|
8
|
+
are getting replaced dynamically with actual values from project-specific context variables.
|
|
9
|
+
|
|
10
|
+
template files are dynamically compiled into destination files, by evaluating embedded f-string-expressions or
|
|
11
|
+
special replacers. replacers are useful especially to generate python code files because they are syntactically
|
|
12
|
+
treated as comments in the template file, replaceable by code statements or code snippets from external files.
|
|
13
|
+
|
|
14
|
+
use the function :func:`deploy_template` to convert a single template into a destination file. fpr bulk destination
|
|
15
|
+
file deployments from multiple templates, use the :class:`TemplateMngr` class.
|
|
16
|
+
"""
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Callable, Iterable, Optional, Protocol, cast, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from ae.base import ( # type: ignore
|
|
21
|
+
UNSET,
|
|
22
|
+
norm_path, os_path_basename, os_path_isfile, os_path_join, os_path_splitext, read_file, write_file)
|
|
23
|
+
from ae.dynamicod import try_eval # type: ignore
|
|
24
|
+
from ae.literal import Literal # type: ignore
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__version__ = '0.3.2'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEPLOY_LOCK_EXT = '.locked' #: additional file ext; blocking the deployment of a template
|
|
31
|
+
|
|
32
|
+
F_STRINGS_PATH_PFX = 'de_tpl_' #: file name prefix if template contains f-strings
|
|
33
|
+
|
|
34
|
+
MANAGED_FILE_ENCODING = None #: managed file default read/write encoding
|
|
35
|
+
MANAGED_FILE_EXTRA_MODE = 'b' #: managed file default read/write extra mode (binary)
|
|
36
|
+
|
|
37
|
+
MANAGED_FILE_ERROR_COMMENT = '* error: ' #: managed file error comment marker
|
|
38
|
+
MANAGED_FILE_SKIP_COMMENT = '- skip reason: ' #: managed file skip-reason comment marker
|
|
39
|
+
MANAGED_FILE_WARNING_COMMENT = '# ' #: managed file warning comment marker
|
|
40
|
+
|
|
41
|
+
PATH_PREFIXES_ARGS_SEP = '_' #: seperator/suffix of template file/path prefixes arguments
|
|
42
|
+
|
|
43
|
+
REFRESHABLE_TEMPLATE_MARKER = 'THIS FILE IS EXCLUSIVELY MAINTAINED'
|
|
44
|
+
""" to mark the content (header) of a refreshable project file that gets created and updated from a template. """
|
|
45
|
+
REFRESHABLE_TEMPLATE_PATH_PFX = 'de_otf_'
|
|
46
|
+
""" file name prefix of an refreshable/externally maintained file, that get created and updated from a template. """
|
|
47
|
+
|
|
48
|
+
STOP_PARSING_PATH_PFX = '_z_' #: file name prefix to support template of template
|
|
49
|
+
|
|
50
|
+
TEMPLATE_PLACEHOLDER_ID_PREFIX = "# " #: template replacers id prefix marker
|
|
51
|
+
TEMPLATE_PLACEHOLDER_ID_SUFFIX = "#(" #: template replacers id suffix marker
|
|
52
|
+
TEMPLATE_PLACEHOLDER_ARGS_SUFFIX = ")#" #: template replacers args suffix marker
|
|
53
|
+
TEMPLATE_INCLUDE_FILE_PLACEHOLDER_ID = "IncludeFile" #: replacers id of :func:`replace_with_file_content_or_default`
|
|
54
|
+
TEMPLATE_REPLACE_WITH_PLACEHOLDER_ID = "ReplaceWith" #: replacers id of :func:`replace_with_template_args`
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# types ---------------------------------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
ContentTransformer = Callable[['ManagedFile'], str] #: text file content transformer function
|
|
60
|
+
ContentType = str | bytes | None #: content type of managed file (None==file-not-exists)
|
|
61
|
+
|
|
62
|
+
ContextVars = dict[str, Any] #: template placeholder variables to be replaced by its value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# pylint: disable=missing-class-docstring,too-few-public-methods
|
|
66
|
+
@runtime_checkable # PathPrefixesFunc = Callable[['ManagedFile'], None] does not support multiple *args
|
|
67
|
+
class PathPrefixesFunc(Protocol): #: path prefixes parser function
|
|
68
|
+
def __call__(self, managed_file: 'ManagedFile', *path_prefix_args: str) -> None: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
PathPrefixesParsers = dict[str, tuple[int, PathPrefixesFunc]] #: registered path prefixes parsers
|
|
72
|
+
|
|
73
|
+
PathPrefixesArgCounts = Iterable[tuple[str, int]] #: path prefixes with its arg counts
|
|
74
|
+
PathPrefixesArgs = list[tuple[str, tuple[str, ...]]] #: path prefixes with its arg values
|
|
75
|
+
|
|
76
|
+
Replacer = Callable[[str], str] #: template content replacers function type
|
|
77
|
+
|
|
78
|
+
TemplateFiles = list[tuple[str, str, str]] #: (patcher id, template file path, destination path prefixes)
|
|
79
|
+
|
|
80
|
+
TplVars = dict[str, Any] #: template placeholder variables to be replaced by its value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ManagedFile: # pylint: disable=too-many-instance-attributes
|
|
84
|
+
""" represents a template/managed file """
|
|
85
|
+
def __init__(self, manager: 'TemplateMngr', patcher: str, template_path: str, dst_path: str = "."):
|
|
86
|
+
""" create new managed file instance
|
|
87
|
+
|
|
88
|
+
:param manager: :class:`TemplateMngr` instance, to reference path prefixes, context vars and replacers.
|
|
89
|
+
:param patcher: templates collection (project) name and version (to be added into the destination file).
|
|
90
|
+
:param template_path: template/source file path.
|
|
91
|
+
:param dst_path: destination file path with optional path prefixes in its file and/or folder names.
|
|
92
|
+
"""
|
|
93
|
+
self.manager = manager
|
|
94
|
+
self.patcher = patcher
|
|
95
|
+
self.template_path = template_path
|
|
96
|
+
self._dst_file_path = patch_string(dst_path, manager.context_vars)
|
|
97
|
+
self._dst_path_stripped = False
|
|
98
|
+
self._dst_path_extension = ""
|
|
99
|
+
|
|
100
|
+
self._content_transformers: list[ContentTransformer] = []
|
|
101
|
+
|
|
102
|
+
self.file_content: ContentType = None
|
|
103
|
+
self._file_encoding = UNSET
|
|
104
|
+
self._file_mode = UNSET
|
|
105
|
+
self.old_content: ContentType = None #: old dst file content loaded if unskipped in path prefixes
|
|
106
|
+
|
|
107
|
+
self.comments: list[str] = [] #: to collect comments, errors and skip-reasons of this managed file
|
|
108
|
+
|
|
109
|
+
self.refreshable = False #: set to True in path prefix parser to allow to overwrite destination file
|
|
110
|
+
|
|
111
|
+
def add_content_transformer(self, tf: ContentTransformer, extra_mode: str = '', encoding: str | None = None):
|
|
112
|
+
""" add a content transformer callable to this managed file.
|
|
113
|
+
|
|
114
|
+
:param tf: content transformer callable, to be called with this instance as argument and returning
|
|
115
|
+
the new/transformed content.
|
|
116
|
+
:param extra_mode: extra file mode (passed to :func:`~ae.base.read_file`/:func:`~ae.base.write_file`).
|
|
117
|
+
:param encoding: content encoding (passed to :func:`~ae.base.read_file`/:func:`~ae.base.write_file`).
|
|
118
|
+
"""
|
|
119
|
+
if self._file_mode not in (UNSET, extra_mode):
|
|
120
|
+
self.error(f"file extra mode mismatch {extra_mode=} != {self._file_mode=}")
|
|
121
|
+
self._file_mode = extra_mode
|
|
122
|
+
|
|
123
|
+
if self._file_encoding not in (UNSET, encoding):
|
|
124
|
+
self.error(f"file encoding mismatch {encoding=} != {self._file_encoding=}")
|
|
125
|
+
self._file_encoding = encoding
|
|
126
|
+
|
|
127
|
+
self._content_transformers.append(tf)
|
|
128
|
+
|
|
129
|
+
def content_transformations(self):
|
|
130
|
+
""" load the file contents of the source and destination file and run all the collected content transformers """
|
|
131
|
+
if self.file_content is None:
|
|
132
|
+
self.file_content = read_file(self.template_path, extra_mode=self.file_mode, encoding=self.file_encoding)
|
|
133
|
+
|
|
134
|
+
if self.old_content is None and os_path_isfile(dst_file_path := self.dst_file_path):
|
|
135
|
+
self.old_content = read_file(dst_file_path, extra_mode=self.file_mode, encoding=self.file_encoding)
|
|
136
|
+
|
|
137
|
+
for content_transformer in self._content_transformers:
|
|
138
|
+
self.file_content = content_transformer(self)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def dst_file_path(self) -> str:
|
|
142
|
+
""" return relative destination path of this managed file (cleaned from path/file name prefixes). """
|
|
143
|
+
if self._dst_path_stripped:
|
|
144
|
+
return self._dst_file_path
|
|
145
|
+
|
|
146
|
+
man = self.manager
|
|
147
|
+
return prefix_parser(self._dst_file_path, man.path_prefixes_arg_counts, args_sep=man.path_prefixes_args_sep)[0]
|
|
148
|
+
|
|
149
|
+
def extend_dst_file_path(self, ext_path: str):
|
|
150
|
+
""" extend destination file path with the specified folder path.
|
|
151
|
+
|
|
152
|
+
:param ext_path: path to extend the destination path with, e.g. move from project root to package path.
|
|
153
|
+
"""
|
|
154
|
+
if self._dst_path_extension:
|
|
155
|
+
self.warning(f"multiple destination path extension overwriting '{self._dst_path_extension}' w/ {ext_path=}")
|
|
156
|
+
self._dst_path_extension = ext_path
|
|
157
|
+
|
|
158
|
+
def error(self, message: str):
|
|
159
|
+
""" add an error comment to this managed file.
|
|
160
|
+
|
|
161
|
+
:param message: error comment text to add.
|
|
162
|
+
"""
|
|
163
|
+
self.comments.append(MANAGED_FILE_ERROR_COMMENT + message)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def file_encoding(self) -> str | None:
|
|
167
|
+
""" return the encoding of this managed file. """
|
|
168
|
+
return MANAGED_FILE_ENCODING if self._file_mode is UNSET else self._file_encoding
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def file_mode(self) -> str:
|
|
172
|
+
""" return the file mode of this managed file. """
|
|
173
|
+
return MANAGED_FILE_EXTRA_MODE if self._file_mode is UNSET else self._file_mode
|
|
174
|
+
|
|
175
|
+
def process_path_prefixes(self) -> bool:
|
|
176
|
+
""" parse, reduce and call back the template file name/path prefixes to check for early deploy skip or errors.
|
|
177
|
+
|
|
178
|
+
:return: False if one of the path prefixes parsers errored or skipped this managed file.
|
|
179
|
+
"""
|
|
180
|
+
arg_counts = self.manager.path_prefixes_arg_counts
|
|
181
|
+
prefixes_parsers = self.manager.path_prefixes_parsers
|
|
182
|
+
prefixes_args_sep = self.manager.path_prefixes_args_sep
|
|
183
|
+
|
|
184
|
+
stripped_dst_path, prefixes_args = prefix_parser(self._dst_file_path, arg_counts, args_sep=prefixes_args_sep)
|
|
185
|
+
|
|
186
|
+
refreshable_args = None
|
|
187
|
+
for prefix, args in prefixes_args:
|
|
188
|
+
if prefix == REFRESHABLE_TEMPLATE_PATH_PFX:
|
|
189
|
+
if refreshable_args is not None:
|
|
190
|
+
self.warning(f"ignoring multiple {REFRESHABLE_TEMPLATE_PATH_PFX=} in {self._dst_file_path}")
|
|
191
|
+
refreshable_args = args # postpone call of refreshable content check to have the final file content
|
|
192
|
+
continue
|
|
193
|
+
func = prefixes_parsers[prefix][1]
|
|
194
|
+
func(self, *args)
|
|
195
|
+
|
|
196
|
+
if refreshable_args is not None:
|
|
197
|
+
prefixes_parsers[REFRESHABLE_TEMPLATE_PATH_PFX][1](self, *refreshable_args)
|
|
198
|
+
|
|
199
|
+
self._dst_file_path = os_path_join(self._dst_path_extension, stripped_dst_path)
|
|
200
|
+
self._dst_path_stripped = True
|
|
201
|
+
return not self.skip_or_error
|
|
202
|
+
|
|
203
|
+
def skip(self, message: str):
|
|
204
|
+
""" add a skip reason comment to this managed file.
|
|
205
|
+
|
|
206
|
+
:param message: skip reason comment text to add.
|
|
207
|
+
"""
|
|
208
|
+
self.comments.append(MANAGED_FILE_SKIP_COMMENT + message)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def skip_or_error(self) -> bool:
|
|
212
|
+
""" return True if this managed file got skipped or added an error comment. """
|
|
213
|
+
return any(_.startswith((MANAGED_FILE_ERROR_COMMENT, MANAGED_FILE_SKIP_COMMENT)) for _ in self.comments)
|
|
214
|
+
|
|
215
|
+
def warning(self, message: str):
|
|
216
|
+
""" add a warning comment to this managed file.
|
|
217
|
+
|
|
218
|
+
:param message: warning comment text to add.
|
|
219
|
+
"""
|
|
220
|
+
self.comments.append(MANAGED_FILE_WARNING_COMMENT + message)
|
|
221
|
+
|
|
222
|
+
def write_file_content(self):
|
|
223
|
+
""" deploy file content of this managed file to its :attr:`dst_file_path`, creating not-existing folders. """
|
|
224
|
+
write_file(self.dst_file_path, self.file_content,
|
|
225
|
+
extra_mode=self.file_mode, encoding=self.file_encoding, make_dirs=True)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TemplateMngr:
|
|
229
|
+
""" checks/deploys/renews managed files from templates and context variables.
|
|
230
|
+
|
|
231
|
+
.. hint::
|
|
232
|
+
example usages of this class can be found in the helper functions :func:`deploy_template` (in this module)
|
|
233
|
+
and :func:`~aedev.project_manager.templates.check_templates` (of the ``pjm`` project-manager tool).
|
|
234
|
+
"""
|
|
235
|
+
def __init__(self, template_files: TemplateFiles, prefix_parsers: PathPrefixesParsers, context_vars: ContextVars,
|
|
236
|
+
*, prefix_args_sep: str = PATH_PREFIXES_ARGS_SEP, **replacers: Replacer):
|
|
237
|
+
"""
|
|
238
|
+
:param template_files: list of template description tuples with
|
|
239
|
+
[0]: patcher, e.g. the template project & version,
|
|
240
|
+
[1]: path to the template file,
|
|
241
|
+
[2]: destination file path with optional f-string-placeholders and path prefixes.
|
|
242
|
+
order this list by priority, because if there is more than one template results in the
|
|
243
|
+
same destination file path, then only the first one will be deployed.
|
|
244
|
+
:param prefix_parsers: template file/path prefixes as mapping of a path prefix string to a tuple of
|
|
245
|
+
a path prefix arg count and a processing/parsing callee.
|
|
246
|
+
:param context_vars: context variables dict to replace template placeholders. to get more globals (by
|
|
247
|
+
:func:`~ae.dynamicod.try_eval`) extend this argument with a '_add_base_globals' key.
|
|
248
|
+
:param prefix_args_sep: path prefixes arguments separator/suffix; defaults to :data:`PATH_PREFIXES_ARGS_SEP`.
|
|
249
|
+
:param replacers: optional replacers: key=placeholder-id and value=replacer callable.
|
|
250
|
+
|
|
251
|
+
"""
|
|
252
|
+
self.template_files = template_files
|
|
253
|
+
|
|
254
|
+
glo_vars = globals().copy() # provide globals of this module, e.g., os.linesep, TEMPLATE_*, ...
|
|
255
|
+
glo_vars.update(context_vars) # plus context vars, e.g., PDV_COMMIT_MSG_FILE_NAME (.gitignore/index.rst)
|
|
256
|
+
self.context_vars = glo_vars
|
|
257
|
+
|
|
258
|
+
assert not any(prefix == STOP_PARSING_PATH_PFX for prefix in prefix_parsers.keys())
|
|
259
|
+
self.path_prefixes_parsers = prefix_parsers
|
|
260
|
+
self.path_prefixes_args_sep = prefix_args_sep
|
|
261
|
+
|
|
262
|
+
self.replacers = replacers
|
|
263
|
+
|
|
264
|
+
self.managed_files: list[ManagedFile] = []
|
|
265
|
+
self.deploy_files: dict[str, ManagedFile] = {}
|
|
266
|
+
self._compile()
|
|
267
|
+
|
|
268
|
+
def _compile(self):
|
|
269
|
+
for patcher, tpl_file_path, dst_path in self.template_files:
|
|
270
|
+
mf = ManagedFile(self, patcher, tpl_file_path, dst_path)
|
|
271
|
+
self.managed_files.append(mf)
|
|
272
|
+
|
|
273
|
+
if mf.process_path_prefixes():
|
|
274
|
+
dst_file_path = norm_path(mf.dst_file_path)
|
|
275
|
+
if os_path_isfile(dst_file_path + DEPLOY_LOCK_EXT):
|
|
276
|
+
mf.skip("destination .locked file exists")
|
|
277
|
+
elif not mf.refreshable and os_path_isfile(dst_file_path):
|
|
278
|
+
mf.skip("destination file of this not refreshable template already exists")
|
|
279
|
+
elif dst_file_path in self.deploy_files:
|
|
280
|
+
mf.skip(f"lower priority than {self.deploy_files[dst_file_path].template_path}")
|
|
281
|
+
else:
|
|
282
|
+
mf.content_transformations()
|
|
283
|
+
if not mf.skip_or_error:
|
|
284
|
+
self.deploy_files[dst_file_path] = mf
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def checked_files(self) -> set[str]:
|
|
288
|
+
""" return a set of destination file paths of all the managed/checked template files. """
|
|
289
|
+
return set(mf.dst_file_path for mf in self.managed_files)
|
|
290
|
+
|
|
291
|
+
def deploy(self):
|
|
292
|
+
""" deploy all the missing/outdated managed files. """
|
|
293
|
+
for mf in self.deploy_files.values():
|
|
294
|
+
mf.write_file_content()
|
|
295
|
+
|
|
296
|
+
def log_lines(self, verbose: bool = False) -> list[str]:
|
|
297
|
+
""" return a list of the log lines of all the managed/checked template files.
|
|
298
|
+
|
|
299
|
+
:param verbose: pass True to get more verbose log entries and include also not deployed files.
|
|
300
|
+
:return: list of log entry lines (preformatted with indent to direct console printout).
|
|
301
|
+
"""
|
|
302
|
+
lines = []
|
|
303
|
+
for mf in self.managed_files if verbose else self.deploy_files.values():
|
|
304
|
+
dst_file_path = mf.dst_file_path
|
|
305
|
+
tpl_file = mf.template_path if verbose else os_path_basename(mf.template_path)
|
|
306
|
+
lines.append(f" = {dst_file_path} from template {tpl_file} ({mf.patcher})")
|
|
307
|
+
if verbose and (not mf.skip_or_error or mf in self.deploy_files.values()):
|
|
308
|
+
lines.append(" " * 6 + "+ " + ("overwrite/refresh" if os_path_isfile(dst_file_path) else "add/miss"))
|
|
309
|
+
for comment in mf.comments:
|
|
310
|
+
if verbose or comment.startswith(MANAGED_FILE_ERROR_COMMENT):
|
|
311
|
+
lines.append(" " * 6 + comment)
|
|
312
|
+
return lines
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def missing_files(self) -> set[str]:
|
|
316
|
+
""" return a set of destination file paths of the missing files created from templates. """
|
|
317
|
+
return set(dst_file_path for mf in self.managed_files
|
|
318
|
+
if not os_path_isfile(dst_file_path := mf.dst_file_path) and not mf.skip_or_error)
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def outdated_files(self) -> list[tuple[str, ContentType, ContentType]]:
|
|
322
|
+
""" list of tuples of destination file path, new, and old file contents for each outdated refreshable file. """
|
|
323
|
+
return [(dst_file_path, mf.file_content, mf.old_content) for mf in self.managed_files
|
|
324
|
+
if os_path_isfile(dst_file_path := mf.dst_file_path) and not mf.skip_or_error]
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def path_prefixes_arg_counts(self) -> PathPrefixesArgCounts:
|
|
328
|
+
""" iterable of tuples wit the prefix id/string and its args count for all registered path prefixes. """
|
|
329
|
+
return [(prefix, arg_count) for prefix, (arg_count, _callee) in self.path_prefixes_parsers.items()]
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def skipped_files(self) -> set[str]:
|
|
333
|
+
""" return a set of destination file paths of the skipped or erroneous managed files. """
|
|
334
|
+
return set(mf.dst_file_path for mf in self.managed_files if mf.skip_or_error)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# global helpers -----------------------------------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def deploy_template(template_file_path: str, dst_path: str = ".", patcher: str = 'deploy_template_default_patcher',
|
|
341
|
+
prefixes_parsers: Optional[PathPrefixesParsers] = None, tpl_vars: Optional[TplVars] = None) -> str:
|
|
342
|
+
""" create/update a file from a template.
|
|
343
|
+
|
|
344
|
+
:param template_file_path: template/source file path.
|
|
345
|
+
:param dst_path: destination file name and path with optional path prefixes/args (will be removed).
|
|
346
|
+
defaults to the current working directory if not specified.
|
|
347
|
+
:param patcher: patcher id string, e.g. the template project & version.
|
|
348
|
+
:param prefixes_parsers: mapping of a prefix to a tuple of the prefix args count and the prefix callable/parser.
|
|
349
|
+
:param tpl_vars: template/project env/dev variables dict of the destination project to patch/refresh.
|
|
350
|
+
providing values for the file/path names, the templates f-string placeholders, and the
|
|
351
|
+
path prefix parser callees (e.g. to specify the `project_type`, `project_path` or
|
|
352
|
+
`package_path` values).
|
|
353
|
+
:return: absolute and stripped destination file path if template got deployed,
|
|
354
|
+
or an empty string if any error or skip reason occurred.
|
|
355
|
+
"""
|
|
356
|
+
man = TemplateMngr([(patcher, template_file_path, dst_path)],
|
|
357
|
+
prefixes_parsers or DEFAULT_PATH_PREFIXES_PARSERS,
|
|
358
|
+
tpl_vars or {})
|
|
359
|
+
man.deploy()
|
|
360
|
+
return next(iter(man.deploy_files), "")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def patch_refreshable_content(file_name: str, content: str, patcher: str) -> str:
|
|
364
|
+
""" create/update the content of a refreshable text file with placeholders (compiled from a template file).
|
|
365
|
+
|
|
366
|
+
:param file_name: the name (and path) of the file to create/update/patch.
|
|
367
|
+
:param content: the content of the file (without the placeholder template marker).
|
|
368
|
+
:param patcher: patching entity/project name/version, to be placed with the placeholder template marker.
|
|
369
|
+
:return: the patched content of the text file (with updated outsource marker).
|
|
370
|
+
"""
|
|
371
|
+
ext = os_path_splitext(file_name)[1]
|
|
372
|
+
sep = os.linesep
|
|
373
|
+
if ext == '.md':
|
|
374
|
+
beg, end = "<!-- ", " -->"
|
|
375
|
+
elif ext == '.rst':
|
|
376
|
+
beg, end = f"{sep}..{sep} ", sep
|
|
377
|
+
else:
|
|
378
|
+
beg, end = "# ", ""
|
|
379
|
+
return f"{beg}{REFRESHABLE_TEMPLATE_MARKER} {patcher}{end}{sep}{content}"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def patch_string(content: str, tpl_vars: ContextVars, **replacers: Replacer) -> str:
|
|
383
|
+
""" replace f-string / dynamic placeholders in content with variable values / return values of replacers callables.
|
|
384
|
+
|
|
385
|
+
:param content: f-string to patch (e.g., a template file's content).
|
|
386
|
+
:param tpl_vars: dict with variables used as globals for f-string replacements. extend this argument with
|
|
387
|
+
a '_add_base_globals' key to add useful globals (see :func:`~ae.dynamicod.try_eval`).
|
|
388
|
+
:param replacers: optional kwargs dict with key/name=placeholder-id and value=replacer-callable.
|
|
389
|
+
to specify additional replacers and also to overwrite or to deactivate the default
|
|
390
|
+
template placeholder replacers specified in :data:`DEFAULT_TEMPLATE_PLACEHOLDERS`
|
|
391
|
+
:return: string resulting from the evaluation of the specified content f-string and from the
|
|
392
|
+
default and additionally specified template :paramref;`~patch_string.replacers`.
|
|
393
|
+
:raises AssertionError: if :data:`TEMPLATE_PLACEHOLDER_ARGS_SUFFIX` is a replacers comment.
|
|
394
|
+
:raises Exception: if evaluation of the :paramref;`~patch_string.content` f-string failed (because of
|
|
395
|
+
missing-globals-NameError/SyntaxError/ValueError/...).
|
|
396
|
+
"""
|
|
397
|
+
content = try_eval('f"""' + content.replace('"""', r'\"\"\"') + '"""', glo_vars=tpl_vars)
|
|
398
|
+
if not content:
|
|
399
|
+
return ""
|
|
400
|
+
content = content.replace(r'\"\"\"', '"""') # recover docstring delimiters
|
|
401
|
+
|
|
402
|
+
suffix = TEMPLATE_PLACEHOLDER_ARGS_SUFFIX
|
|
403
|
+
len_suf = len(suffix)
|
|
404
|
+
all_replacers = DEFAULT_REPLACERS
|
|
405
|
+
all_replacers.update(replacers)
|
|
406
|
+
for key, fun in all_replacers.items():
|
|
407
|
+
prefix = TEMPLATE_PLACEHOLDER_ID_PREFIX + key + TEMPLATE_PLACEHOLDER_ID_SUFFIX
|
|
408
|
+
len_pre = len(prefix)
|
|
409
|
+
|
|
410
|
+
beg = 0
|
|
411
|
+
while True:
|
|
412
|
+
beg = content.find(prefix, beg)
|
|
413
|
+
if beg == -1:
|
|
414
|
+
break
|
|
415
|
+
|
|
416
|
+
end = content.find(suffix, beg)
|
|
417
|
+
assert end != -1, f"patch_string() {key=} placeholder args-{suffix=} is missing in {content=}; {tpl_vars=}"
|
|
418
|
+
|
|
419
|
+
replacement = fun(content[beg + len_pre: end])
|
|
420
|
+
if isinstance(replacement, str):
|
|
421
|
+
content = content[:beg] + replacement + content[end + len_suf:]
|
|
422
|
+
|
|
423
|
+
return content
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def path_pfx_parametrize_with_context(managed_file: ManagedFile, *_args: str):
|
|
427
|
+
""" path prefix callee for the :data:`F_STRINGS_PATH_PFX` prefix.
|
|
428
|
+
|
|
429
|
+
:param managed_file: ManagedFile instance.
|
|
430
|
+
"""
|
|
431
|
+
managed_file.add_content_transformer(transform_parametrize_content)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def path_pfx_refreshable_content(managed_file: ManagedFile, *_args: str):
|
|
435
|
+
""" path prefix callee for the :data:`REFRESHABLE_TEMPLATE_PATH_PFX` prefix.
|
|
436
|
+
|
|
437
|
+
:param managed_file: ManagedFile instance.
|
|
438
|
+
"""
|
|
439
|
+
managed_file.refreshable = True
|
|
440
|
+
managed_file.add_content_transformer(transform_refreshable_content) # postpone check of REFRESHABLE_TEMPLATE_MARKER
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
DEFAULT_PATH_PREFIXES_PARSERS: PathPrefixesParsers = {
|
|
444
|
+
F_STRINGS_PATH_PFX: (0, path_pfx_parametrize_with_context),
|
|
445
|
+
REFRESHABLE_TEMPLATE_PATH_PFX: (0, path_pfx_refreshable_content),
|
|
446
|
+
}
|
|
447
|
+
""" mapping of the default path prefixes parsers with to a tuple of the prefix args count and the parser callee. """
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def prefix_parser(dst_path: str, prefixes_arg_counts: PathPrefixesArgCounts, args_sep: str = PATH_PREFIXES_ARGS_SEP
|
|
451
|
+
) -> tuple[str, PathPrefixesArgs]:
|
|
452
|
+
""" detect path/file name prefixes including their prefix args returning stripped path/file name and prefixes args.
|
|
453
|
+
|
|
454
|
+
:param dst_path: destination file path to parse for path/file name prefixes and args.
|
|
455
|
+
:param prefixes_arg_counts: iterable of tuples with a prefix and the number of args of each prefix.
|
|
456
|
+
:param args_sep: string/char used as path prefixes args seperator/suffix
|
|
457
|
+
:return: tuple with stripped path/file name and a list of tuples with the removed prefix and its
|
|
458
|
+
prefix args.
|
|
459
|
+
"""
|
|
460
|
+
parts = []
|
|
461
|
+
prefixes_args = []
|
|
462
|
+
for name in dst_path.split(os.path.sep):
|
|
463
|
+
name_rest, *name_suffixes = name.split(STOP_PARSING_PATH_PFX, maxsplit=1)
|
|
464
|
+
while match := next(((pfx, cnt) for pfx, cnt in prefixes_arg_counts if name_rest.startswith(pfx)), None):
|
|
465
|
+
prefix, arg_count = match
|
|
466
|
+
args_and_rest = name_rest[len(prefix):].split(args_sep, maxsplit=arg_count)
|
|
467
|
+
prefixes_args.append((prefix, tuple(args_and_rest[:arg_count])))
|
|
468
|
+
name_rest = args_and_rest[-1]
|
|
469
|
+
parts.append("".join([name_rest] + name_suffixes))
|
|
470
|
+
|
|
471
|
+
return os_path_join(*parts), prefixes_args
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def replace_with_file_content_or_default(args_str: str) -> str:
|
|
475
|
+
""" return file content if the file specified in first string arg exists, else return empty string or 2nd arg str.
|
|
476
|
+
|
|
477
|
+
:param args_str: pass either file name, or file name and default literal separated by a comma character.
|
|
478
|
+
whitespace (spaces, tabs, newline, cr) get removed from the start/end of the file name.
|
|
479
|
+
a default literal gets parsed like a config variable, the literal value gets return.
|
|
480
|
+
:return: file content or default literal value or empty string (if the file does not exist and
|
|
481
|
+
there is no comma char in :paramref:`~replace_with_file_content_or_default.args_str`).
|
|
482
|
+
"""
|
|
483
|
+
file_name, *default = args_str.split(",", maxsplit=1)
|
|
484
|
+
file_name = file_name.strip() # strip any surrounding spaces, tabs, and newlines
|
|
485
|
+
return read_file(file_name) if os_path_isfile(file_name) else Literal(default[0]).value if default else ""
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def replace_with_template_args(args_str: str) -> str:
|
|
489
|
+
""" template placeholder replacer function to hide uncompleted code from code-inspections/editor-warnings.
|
|
490
|
+
|
|
491
|
+
:param args_str: args string to return, replacing the template placeholder (interpreted as comment in
|
|
492
|
+
python code).
|
|
493
|
+
:return: args string specified as argument of the :data:`TEMPLATE_REPLACE_WITH_PLACEHOLDER_ID`.
|
|
494
|
+
"""
|
|
495
|
+
return args_str
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
DEFAULT_REPLACERS = {
|
|
499
|
+
TEMPLATE_INCLUDE_FILE_PLACEHOLDER_ID: replace_with_file_content_or_default,
|
|
500
|
+
TEMPLATE_REPLACE_WITH_PLACEHOLDER_ID: replace_with_template_args,
|
|
501
|
+
}
|
|
502
|
+
""" map of default replacers callables used by :func:`patch_string`. """
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def transform_parametrize_content(managed_file: ManagedFile) -> str:
|
|
506
|
+
""" content transformer callee added via the :data:`F_STRINGS_PATH_PFX` path prefix.
|
|
507
|
+
|
|
508
|
+
:param managed_file: ManagedFile instance.
|
|
509
|
+
:return: transformed file content.
|
|
510
|
+
"""
|
|
511
|
+
manager = managed_file.manager
|
|
512
|
+
return patch_string(cast(str, managed_file.file_content), manager.context_vars, **manager.replacers)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def transform_refreshable_content(managed_file: ManagedFile) -> str:
|
|
516
|
+
""" content transformer callee added via the :data:`REFRESHABLE_TEMPLATE_PATH_PFX` path prefix.
|
|
517
|
+
|
|
518
|
+
:param managed_file: ManagedFile instance.
|
|
519
|
+
:return: transformed file content.
|
|
520
|
+
"""
|
|
521
|
+
if (old_content := managed_file.old_content) and REFRESHABLE_TEMPLATE_MARKER not in old_content[:369]:
|
|
522
|
+
managed_file.skip("missing refreshable content marker in destination file")
|
|
523
|
+
return ""
|
|
524
|
+
|
|
525
|
+
new_content = patch_refreshable_content(managed_file.dst_file_path,
|
|
526
|
+
cast(str, managed_file.file_content),
|
|
527
|
+
managed_file.patcher)
|
|
528
|
+
if old_content == new_content:
|
|
529
|
+
managed_file.skip("refreshable destination file is up-to-date")
|
|
530
|
+
return ""
|
|
531
|
+
|
|
532
|
+
return new_content
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ae_managed_files
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: ae namespace module portion managed_files: managed files
|
|
5
|
+
Home-page: https://gitlab.com/ae-group/ae_managed_files
|
|
6
|
+
Author: AndiEcker
|
|
7
|
+
Author-email: aecker2@gmail.com
|
|
8
|
+
License: GPL-3.0-or-later
|
|
9
|
+
Project-URL: Bug Tracker, https://gitlab.com/ae-group/ae_managed_files/-/issues
|
|
10
|
+
Project-URL: Documentation, https://ae.readthedocs.io/en/latest/_autosummary/ae.managed_files.html
|
|
11
|
+
Project-URL: Repository, https://gitlab.com/ae-group/ae_managed_files
|
|
12
|
+
Project-URL: Source, https://ae.readthedocs.io/en/latest/_modules/ae/managed_files.html
|
|
13
|
+
Keywords: configuration,development,environment,productivity
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Natural Language :: English
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE.md
|
|
25
|
+
Requires-Dist: ae_base
|
|
26
|
+
Requires-Dist: ae_dynamicod
|
|
27
|
+
Requires-Dist: ae_literal
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: aedev_project_tpls; extra == "dev"
|
|
30
|
+
Requires-Dist: ae_ae; extra == "dev"
|
|
31
|
+
Requires-Dist: anybadge; extra == "dev"
|
|
32
|
+
Requires-Dist: coverage-badge; extra == "dev"
|
|
33
|
+
Requires-Dist: flake8; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy; extra == "dev"
|
|
35
|
+
Requires-Dist: pylint; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest; extra == "dev"
|
|
37
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
38
|
+
Requires-Dist: pytest-django; extra == "dev"
|
|
39
|
+
Requires-Dist: typing; extra == "dev"
|
|
40
|
+
Requires-Dist: types-setuptools; extra == "dev"
|
|
41
|
+
Provides-Extra: docs
|
|
42
|
+
Provides-Extra: tests
|
|
43
|
+
Requires-Dist: anybadge; extra == "tests"
|
|
44
|
+
Requires-Dist: coverage-badge; extra == "tests"
|
|
45
|
+
Requires-Dist: flake8; extra == "tests"
|
|
46
|
+
Requires-Dist: mypy; extra == "tests"
|
|
47
|
+
Requires-Dist: pylint; extra == "tests"
|
|
48
|
+
Requires-Dist: pytest; extra == "tests"
|
|
49
|
+
Requires-Dist: pytest-cov; extra == "tests"
|
|
50
|
+
Requires-Dist: pytest-django; extra == "tests"
|
|
51
|
+
Requires-Dist: typing; extra == "tests"
|
|
52
|
+
Requires-Dist: types-setuptools; extra == "tests"
|
|
53
|
+
Dynamic: author
|
|
54
|
+
Dynamic: author-email
|
|
55
|
+
Dynamic: classifier
|
|
56
|
+
Dynamic: description
|
|
57
|
+
Dynamic: description-content-type
|
|
58
|
+
Dynamic: home-page
|
|
59
|
+
Dynamic: keywords
|
|
60
|
+
Dynamic: license
|
|
61
|
+
Dynamic: license-file
|
|
62
|
+
Dynamic: project-url
|
|
63
|
+
Dynamic: provides-extra
|
|
64
|
+
Dynamic: requires-dist
|
|
65
|
+
Dynamic: requires-python
|
|
66
|
+
Dynamic: summary
|
|
67
|
+
|
|
68
|
+
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project ae.ae v0.3.101 -->
|
|
69
|
+
<!-- THIS FILE IS EXCLUSIVELY MAINTAINED by the project aedev.namespace_root_tpls v0.3.22 -->
|
|
70
|
+
# managed_files 0.3.2
|
|
71
|
+
|
|
72
|
+
[](
|
|
73
|
+
https://gitlab.com/ae-group/ae_managed_files)
|
|
74
|
+
[](
|
|
76
|
+
https://gitlab.com/ae-group/ae_managed_files/-/tree/release0.3.2)
|
|
77
|
+
[](
|
|
78
|
+
https://pypi.org/project/ae-managed-files/#history)
|
|
79
|
+
|
|
80
|
+
>ae namespace module portion managed_files: managed files.
|
|
81
|
+
|
|
82
|
+
[](
|
|
83
|
+
https://ae-group.gitlab.io/ae_managed_files/coverage/index.html)
|
|
84
|
+
[](
|
|
85
|
+
https://ae-group.gitlab.io/ae_managed_files/lineprecision.txt)
|
|
86
|
+
[](
|
|
87
|
+
https://ae-group.gitlab.io/ae_managed_files/pylint.log)
|
|
88
|
+
|
|
89
|
+
[](
|
|
90
|
+
https://gitlab.com/ae-group/ae_managed_files/)
|
|
91
|
+
[](
|
|
92
|
+
https://gitlab.com/ae-group/ae_managed_files/)
|
|
93
|
+
[](
|
|
94
|
+
https://gitlab.com/ae-group/ae_managed_files/)
|
|
95
|
+
[](
|
|
96
|
+
https://pypi.org/project/ae-managed-files/)
|
|
97
|
+
[](
|
|
98
|
+
https://gitlab.com/ae-group/ae_managed_files/-/blob/develop/LICENSE.md)
|
|
99
|
+
[](
|
|
100
|
+
https://libraries.io/pypi/ae-managed-files)
|
|
101
|
+
[](
|
|
102
|
+
https://pypi.org/project/ae-managed-files/#files)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
## installation
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
execute the following command to install the
|
|
109
|
+
ae.managed_files module
|
|
110
|
+
in the currently active virtual environment:
|
|
111
|
+
|
|
112
|
+
```shell script
|
|
113
|
+
pip install ae-managed-files
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
if you want to contribute to this portion then first fork
|
|
117
|
+
[the ae_managed_files repository at GitLab](
|
|
118
|
+
https://gitlab.com/ae-group/ae_managed_files "ae.managed_files code repository").
|
|
119
|
+
after that pull it to your machine and finally execute the
|
|
120
|
+
following command in the root folder of this repository
|
|
121
|
+
(ae_managed_files):
|
|
122
|
+
|
|
123
|
+
```shell script
|
|
124
|
+
pip install -e .[dev]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
the last command will install this module portion, along with the tools you need
|
|
128
|
+
to develop and run tests or to extend the portion documentation. to contribute only to the unit tests or to the
|
|
129
|
+
documentation of this portion, replace the setup extras key `dev` in the above command with `tests` or `docs`
|
|
130
|
+
respectively.
|
|
131
|
+
|
|
132
|
+
more detailed explanations on how to contribute to this project
|
|
133
|
+
[are available here](
|
|
134
|
+
https://gitlab.com/ae-group/ae_managed_files/-/blob/develop/CONTRIBUTING.rst)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
## namespace portion documentation
|
|
138
|
+
|
|
139
|
+
information on the features and usage of this portion are available at
|
|
140
|
+
[ReadTheDocs](
|
|
141
|
+
https://ae.readthedocs.io/en/latest/_autosummary/ae.managed_files.html
|
|
142
|
+
"ae_managed_files documentation").
|