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 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
+ [![GitLab develop](https://img.shields.io/gitlab/pipeline/ae-group/ae_managed_files/develop?logo=python)](
73
+ https://gitlab.com/ae-group/ae_managed_files)
74
+ [![LatestPyPIrelease](
75
+ https://img.shields.io/gitlab/pipeline/ae-group/ae_managed_files/release0.3.2?logo=python)](
76
+ https://gitlab.com/ae-group/ae_managed_files/-/tree/release0.3.2)
77
+ [![PyPIVersions](https://img.shields.io/pypi/v/ae_managed_files)](
78
+ https://pypi.org/project/ae-managed-files/#history)
79
+
80
+ >ae namespace module portion managed_files: managed files.
81
+
82
+ [![Coverage](https://ae-group.gitlab.io/ae_managed_files/coverage.svg)](
83
+ https://ae-group.gitlab.io/ae_managed_files/coverage/index.html)
84
+ [![MyPyPrecision](https://ae-group.gitlab.io/ae_managed_files/mypy.svg)](
85
+ https://ae-group.gitlab.io/ae_managed_files/lineprecision.txt)
86
+ [![PyLintScore](https://ae-group.gitlab.io/ae_managed_files/pylint.svg)](
87
+ https://ae-group.gitlab.io/ae_managed_files/pylint.log)
88
+
89
+ [![PyPIImplementation](https://img.shields.io/pypi/implementation/ae_managed_files)](
90
+ https://gitlab.com/ae-group/ae_managed_files/)
91
+ [![PyPIPyVersions](https://img.shields.io/pypi/pyversions/ae_managed_files)](
92
+ https://gitlab.com/ae-group/ae_managed_files/)
93
+ [![PyPIWheel](https://img.shields.io/pypi/wheel/ae_managed_files)](
94
+ https://gitlab.com/ae-group/ae_managed_files/)
95
+ [![PyPIFormat](https://img.shields.io/pypi/format/ae_managed_files)](
96
+ https://pypi.org/project/ae-managed-files/)
97
+ [![PyPILicense](https://img.shields.io/pypi/l/ae_managed_files)](
98
+ https://gitlab.com/ae-group/ae_managed_files/-/blob/develop/LICENSE.md)
99
+ [![PyPIStatus](https://img.shields.io/pypi/status/ae_managed_files)](
100
+ https://libraries.io/pypi/ae-managed-files)
101
+ [![PyPIDownloads](https://img.shields.io/pypi/dm/ae_managed_files)](
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").