envstack 1.0.3__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.
envstack/path.py ADDED
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env python3
2
+ #
3
+ # Copyright (c) 2024-2026, Ryan Galloway (ryan@rsgalloway.com)
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # - Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ #
11
+ # - Redistributions in binary form must reproduce the above copyright notice,
12
+ # this list of conditions and the following disclaimer in the documentation
13
+ # and/or other materials provided with the distribution.
14
+ #
15
+ # - Neither the name of the software nor the names of its contributors
16
+ # may be used to endorse or promote products derived from this software
17
+ # without specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ # POSSIBILITY OF SUCH DAMAGE.
30
+ #
31
+
32
+ __doc__ = """
33
+ Contains template path classes and functions.
34
+ """
35
+
36
+ import os
37
+ import re
38
+ from typing import Iterable, Optional, Tuple
39
+
40
+ from envstack import config, logger
41
+ from envstack.exceptions import * # noqa
42
+
43
+ # env var regex: matches $VAR or ${VAR}
44
+ env_var_re = re.compile(r"\$\{[^}]+\}|\$\w+")
45
+
46
+ # template path field regex: extracts bracketed {keys}
47
+ keyword_re = re.compile(r"{(\w*)(?::\d*d)?(?::\d*\.\d*f)?}")
48
+
49
+ # template path formatting regex
50
+ formats_re = re.compile(r"{([\w\:\.\d]*)}")
51
+
52
+ # template path field backref regex
53
+ field_re = re.compile(r"\(\?P\<(.*?)\>\[\^,;\\\/]\*\)")
54
+
55
+ # template path directory delimiter regex
56
+ directory_re = re.compile(r"[^\\/]+|[\\/]")
57
+
58
+
59
+ def _escape_env_vars(s: str) -> str:
60
+ """Convert ${VAR} -> ${{VAR}} so str.format ignores it, while leaving {token}
61
+ intact."""
62
+
63
+ def repl(m: re.Match) -> str:
64
+ tok = m.group(0)
65
+ if tok.startswith("${"):
66
+ inner = tok[2:-1]
67
+ return "${{" + inner + "}}"
68
+ return tok
69
+
70
+ return env_var_re.sub(repl, s)
71
+
72
+
73
+ def _numdirs(p: str) -> int:
74
+ """Returns the number of directory levels in a path string, used for template"""
75
+ return str(p).replace("\\", "/").count("/")
76
+
77
+
78
+ def _load_resolved_stack(
79
+ stack: str,
80
+ *,
81
+ platform: str = config.PLATFORM,
82
+ scope: Optional[str] = None,
83
+ ):
84
+ """Load + resolve an envstack environment stack. Intentionally uses envstack's
85
+ own resolution model rather than os.environ as the primary source of truth."""
86
+ from .env import load_environ, resolve_environ
87
+
88
+ raw = load_environ(stack, platform=platform, scope=scope)
89
+ return resolve_environ(raw)
90
+
91
+
92
+ def _expand_env_vars(template: str, env: dict) -> str:
93
+ """
94
+ Expand $VARS / ${VARS} in `template` using the provided `env` mapping.
95
+ Uses EnvVar (string.Template-based).
96
+ """
97
+ from .env import EnvVar
98
+
99
+ # EnvVar.expand() returns either EnvVar, list, or dict depending on input
100
+ expanded = EnvVar(template).expand(env, recursive=True)
101
+
102
+ if isinstance(expanded, list) or isinstance(expanded, dict):
103
+ raise InvalidSyntax(
104
+ f"Path template expansion must resolve to a string, got {type(expanded)}"
105
+ )
106
+
107
+ # expanded may already be a string-like EnvVar
108
+ return str(expanded)
109
+
110
+
111
+ def _iter_template_items(env: dict) -> Iterable[Tuple[str, str]]:
112
+ """
113
+ Heuristic filter for "likely path templates" inside an environment.
114
+
115
+ We avoid assuming a special namespace and instead scan the stack for values
116
+ that look like templates.
117
+ """
118
+ for k, v in env.items():
119
+ if not isinstance(v, str) or not v:
120
+ continue
121
+
122
+ # must contain at least one format field; otherwise it's not a template
123
+ if "{" not in v or "}" not in v:
124
+ continue
125
+
126
+ # most path templates contain a separator; kept loose
127
+ if "/" not in v and "\\" not in v:
128
+ continue
129
+
130
+ yield k, v
131
+
132
+
133
+ class Path(str):
134
+ """Subclass of `str` with some platform agnostic pathing support."""
135
+
136
+ SEPARATORS = ["/", "\\"]
137
+
138
+ def __init__(self, path, platform: str = config.PLATFORM):
139
+ self.path = path
140
+ self.platform = platform
141
+
142
+ def __new__(cls, path, platform: str = config.PLATFORM):
143
+ obj = super().__new__(cls, path)
144
+ obj.platform = platform
145
+ return obj
146
+
147
+ def __repr__(self):
148
+ return "<{0} '{1}'>".format(self.__class__.__name__, self.path)
149
+
150
+ def __str__(self):
151
+ return str(self.path)
152
+
153
+ def basename(self):
154
+ """Returns the final component of the path."""
155
+ return os.path.basename(str(self))
156
+
157
+ def dirname(self):
158
+ """Returns the directory component of the path."""
159
+ return os.path.dirname(str(self))
160
+
161
+ def levels(self):
162
+ """Returns number of directory levels in the path."""
163
+ tokens = directory_re.findall(self.path)
164
+ return [t for t in tokens if t not in self.SEPARATORS]
165
+
166
+ def to_platform(
167
+ self,
168
+ platform: str = config.PLATFORM,
169
+ *,
170
+ stack: str = config.DEFAULT_NAMESPACE,
171
+ scope: Optional[str] = None,
172
+ root_var: str = "ROOT",
173
+ ):
174
+ """
175
+ Converts path root from this Path.platform to `platform` using ROOT values
176
+ from the resolved envstack environment for each platform.
177
+
178
+ :param platform: target platform name
179
+ :param stack: envstack stack to load for ROOT values (e.g. 'fps')
180
+ :param scope: scope to resolve stack from (defaults to dirname of this path)
181
+ :param root_var: env var name to treat as platform root (default: ROOT)
182
+ :returns: converted path string
183
+ """
184
+ if platform == self.platform:
185
+ return str(self)
186
+
187
+ scope = scope or self.scope()
188
+ try:
189
+ from_env = _load_resolved_stack(stack, platform=self.platform, scope=scope)
190
+ to_env = _load_resolved_stack(stack, platform=platform, scope=scope)
191
+ except Exception as err:
192
+ raise InvalidPath(
193
+ f"Failed to load stack '{stack}' for platform conversion: {err}"
194
+ )
195
+
196
+ from_root = from_env.get(root_var)
197
+ to_root = to_env.get(root_var)
198
+
199
+ if not from_root or not to_root:
200
+ raise TemplateNotFound(
201
+ f"{root_var} undefined for platform conversion ({self.platform} -> {platform}) "
202
+ f"in stack '{stack}'"
203
+ )
204
+
205
+ # use regex escape in case roots contain special chars
206
+ return re.sub(r"^{}".format(re.escape(from_root)), to_root, self.path)
207
+
208
+ def to_str(self):
209
+ """Returns this path as a string."""
210
+ return str(self)
211
+
212
+ def scope(self):
213
+ """Returns the scope for this path."""
214
+ return os.path.dirname(str(self))
215
+
216
+
217
+ class Template(object):
218
+ """Path Template class."""
219
+
220
+ def __init__(self, path: str):
221
+ assert path, "Template path format cannot be empty"
222
+ self.path_format = str(path)
223
+
224
+ def __repr__(self):
225
+ return f"<Template '{self.path_format}'>"
226
+
227
+ def __str__(self):
228
+ return self.path_format
229
+
230
+ def _parse(self, pattern: str):
231
+ tokens = pattern.split(self.path_format)
232
+ keys = []
233
+ for token in tokens[1::2]:
234
+ if token not in keys:
235
+ keys.append(token)
236
+ return keys
237
+
238
+ def apply_fields(self, **fields):
239
+ """
240
+ Applies key/value pairs matching template format.
241
+
242
+ :param fields: key/values to apply to template.
243
+ :returns: resolved path as Path.
244
+ :raises: MissingFieldError.
245
+ """
246
+ formats = self.get_formats()
247
+
248
+ def cast(k, v):
249
+ fmt = formats.get(k, str)
250
+ try:
251
+ return fmt(v)
252
+ except ValueError:
253
+ raise Exception("{0} must be {1}".format(k, fmt.__name__))
254
+
255
+ formatted = dict((k, cast(k, v)) for k, v in fields.items())
256
+
257
+ try:
258
+ return Path(self.path_format.format(**formatted))
259
+ except KeyError as err:
260
+ raise MissingFieldError(err) # noqa
261
+
262
+ def get_keywords(self):
263
+ """Returns a list of required keywords."""
264
+ return self._parse(keyword_re)
265
+
266
+ def get_formats(self):
267
+ """Returns a map of keywords to value classes."""
268
+ matches = self._parse(formats_re)
269
+ results = {}
270
+ for key in matches:
271
+ _type = str
272
+ if ":" in key:
273
+ key, f = key.split(":")
274
+ if "d" in f:
275
+ _type = int
276
+ elif "f" in f:
277
+ _type = float
278
+ results[key] = _type
279
+ return results
280
+
281
+ def get_fields(self, path: str):
282
+ """Gets key/value pairs from path that map to template path."""
283
+ path = path.replace("\\", "/")
284
+ path_format = self.path_format.replace("\\", "/")
285
+
286
+ tokens = keyword_re.split(path_format)
287
+ keywords = tokens[1::2]
288
+
289
+ tokens[1::2] = map(r"(?P<{}>[^,;\/]*)".format, keywords)
290
+ tokens[0::2] = map(re.escape, tokens[0::2])
291
+
292
+ # look for back references
293
+ for i in range(len(tokens)):
294
+ fm = field_re.match(tokens[i])
295
+ if fm is not None:
296
+ name = fm.group(1)
297
+ back_ref = "(?P={name})".format(name=name)
298
+ try:
299
+ while True:
300
+ index = tokens[i + 1 :].index(tokens[i]) # noqa
301
+ tokens[i + 1 + index] = back_ref
302
+ except ValueError:
303
+ pass
304
+
305
+ pattern = "".join(tokens)
306
+ matches = re.match(pattern, path)
307
+ if not matches:
308
+ raise InvalidPath(path) # noqa
309
+
310
+ formats = self.get_formats()
311
+
312
+ def cast(k, v):
313
+ return formats.get(k, str)(v)
314
+
315
+ return {k: cast(k, matches.group(k)) for k in keywords}
316
+
317
+
318
+ def extract_fields(
319
+ filepath: str,
320
+ template: Template,
321
+ *,
322
+ stack: str = config.DEFAULT_NAMESPACE,
323
+ platform: str = config.PLATFORM,
324
+ ):
325
+ """
326
+ Convenience function that extracts template fields from a given filepath for
327
+ a given template instance or template name.
328
+
329
+ :param filepath: path to file
330
+ :param template: Template instance or name of template in `stack`
331
+ :param stack: stack namespace to load templates from (default: config.DEFAULT_NAMESPACE)
332
+ :param platform: optional platform name
333
+ :returns: dictionary of template fields
334
+ """
335
+ try:
336
+ p = Path(filepath, platform=platform)
337
+ if isinstance(template, str):
338
+ template = get_template(
339
+ template, stack=stack, platform=platform, scope=p.scope()
340
+ )
341
+ return template.get_fields(filepath)
342
+
343
+ except InvalidPath: # noqa
344
+ logger.log.debug(
345
+ "path does not match template: {0} {1}".format(template, filepath)
346
+ )
347
+ return {}
348
+
349
+ except Exception as err:
350
+ logger.log.debug("error extracting fields: {}".format(err))
351
+ return {}
352
+
353
+
354
+ def get_scope(filepath: str):
355
+ """Convenience function that returns the scope of a given filepath."""
356
+ return Path(filepath).scope()
357
+
358
+
359
+ def get_template(
360
+ name: str,
361
+ *,
362
+ stack: str = config.DEFAULT_NAMESPACE,
363
+ platform: str = config.PLATFORM,
364
+ scope: str = None,
365
+ expand: bool = True,
366
+ ):
367
+ """
368
+ Returns a Template instance for a given template name located in `stack`.
369
+
370
+ - Loads + resolves the envstack environment for `stack`
371
+ - Fetches template string from resolved env
372
+ - Expands $VARS / ${VARS} inside the template string using the resolved env
373
+ - Returns Template(expanded_string)
374
+
375
+ :param name: template variable name
376
+ :param stack: envstack stack to load (e.g. 'fps')
377
+ :param platform: platform name
378
+ :param scope: scope (default: cwd via load_environ)
379
+ :param expand: whether to expand $VARS in the template string
380
+ """
381
+ env = _load_resolved_stack(stack, platform=platform, scope=scope)
382
+
383
+ template = env.get(name)
384
+ if not template:
385
+ raise TemplateNotFound(name) # noqa
386
+
387
+ if expand:
388
+ template = _expand_env_vars(template, env)
389
+ else:
390
+ template = _escape_env_vars(template)
391
+
392
+ return Template(template)
393
+
394
+
395
+ def match_template(
396
+ path: str,
397
+ *,
398
+ stack: str = config.DEFAULT_NAMESPACE,
399
+ platform: str = config.PLATFORM,
400
+ scope: str = None,
401
+ expand: bool = True,
402
+ ):
403
+ """
404
+ Returns a Template that matches a given `path`.
405
+
406
+ - Loads + resolves `stack`
407
+ - Considers values that look like path templates
408
+ - Orders templates by directory depth (more specific first)
409
+ - Returns first matching template, or raises ValueError if the path matches
410
+ multiple templates of the same depth.
411
+
412
+ :param path: path to match against templates
413
+ :param stack: envstack stack to load (e.g. 'fps')
414
+ :param platform: platform name
415
+ :param scope: scope (default: cwd via load_environ)
416
+ :param expand: whether to expand $VARS in the template strings before matching
417
+ :raises: ValueError if `path` matches multiple templates at the same depth
418
+ :returns: matching Template or None
419
+ """
420
+ env = _load_resolved_stack(stack, platform=platform, scope=scope)
421
+
422
+ items = list(_iter_template_items(env))
423
+ items.sort(key=lambda kv: _numdirs(kv[1]), reverse=True)
424
+
425
+ matched = None
426
+ matched_depth = None
427
+
428
+ for name, path_format in items:
429
+ try:
430
+ if expand:
431
+ path_format_expanded = _expand_env_vars(path_format, env)
432
+ else:
433
+ path_format_expanded = path_format
434
+
435
+ template_test = Template(path_format_expanded)
436
+
437
+ if template_test.get_fields(path):
438
+ depth = _numdirs(template_test.path_format)
439
+ if matched is None:
440
+ matched = template_test
441
+ matched_depth = depth
442
+ elif depth == matched_depth:
443
+ raise ValueError("path matches more than one template")
444
+
445
+ except InvalidPath: # noqa
446
+ continue
447
+
448
+ return matched