wend 0.1.0__tar.gz

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.
wend-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.3
2
+ Name: wend
3
+ Version: 0.1.0
4
+ Summary: Templated paths using t-strings
5
+ Keywords: paths,t-strings
6
+ Author: Louis Maddox
7
+ Author-email: Louis Maddox <louismmx@gmail.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Natural Language :: English
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: Python :: Implementation :: CPython
17
+ Requires-Python: >=3.14
18
+ Project-URL: Homepage, https://github.com/lmmx/wend
19
+ Project-URL: Repository, https://github.com/lmmx/wend.git
20
+ Description-Content-Type: text/markdown
21
+
22
+ # wend
23
+
24
+ [![PyPI version](https://img.shields.io/pypi/v/wend.svg)](https://pypi.org/project/wend/)
25
+ [![Python versions](https://img.shields.io/pypi/pyversions/wend.svg)](https://pypi.org/project/wend/)
26
+ [![License](https://img.shields.io/pypi/l/wend.svg)](https://github.com/lmmx/wend/blob/master/LICENSE)
27
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/lmmx/wend/master.svg)](https://results.pre-commit.ci/latest/github/lmmx/wend/master)
28
+
29
+ A library for templated paths using Python t-strings (3.14+)
30
+
31
+ ## Installation
32
+
33
+ ```sh
34
+ uv pip install wend
35
+ ````
36
+
37
+ ## Requirements
38
+
39
+ - Python 3.14+
40
+ - Git (with sparse-checkout support)
41
+
42
+ ## About
43
+
44
+ For backstory read my blog about this package [cog.spin.systems/future-paths][blog]!
45
+
46
+ [blog]: https://cog.spin.systems/future-paths
47
+
48
+ The general idea is to use t-strings to house eagerly instantiated parameter objects which offer a
49
+ form of deferred execution.
50
+
51
+ ## Usage
52
+
53
+ Following the examples in the [blog post][blog]:
54
+
55
+ ```py
56
+ from pathlib import Path
57
+ from wend import Param, P, T, RelativePath
58
+
59
+ # Define parameters (late-bound)
60
+ root = Param("root")
61
+ dataset = Param("dataset")
62
+ idx = Param("idx")
63
+ total = Param("total")
64
+
65
+ # Build expressions - these don't touch the filesystem
66
+ data_dir = root / "data" / dataset
67
+ chunk_file = data_dir / T(t"chunk_{idx:04d}-of-{total:04d}.parquet")
68
+
69
+ # Inspect what's needed
70
+ print("Required params:", chunk_file.required_params())
71
+ # -> {'root', 'dataset', 'idx', 'total'}
72
+
73
+ # Resolve with bindings
74
+ path = chunk_file.resolve({
75
+ "root": "/mnt/storage",
76
+ "dataset": "train",
77
+ "idx": 7,
78
+ "total": 100,
79
+ })
80
+ print("Resolved:", path)
81
+ # -> /mnt/storage/data/train/chunk_0007-of-0100.parquet
82
+
83
+ # Constant folding happens at construction
84
+ folded = P("/home") / "user" / "data"
85
+ print("Folded type:", type(folded).__name__)
86
+ # -> LiteralExpr (not nested JoinExprs)
87
+
88
+ # Parent simplification
89
+ expr = root / "a" / "b"
90
+ print("Parent of join:", expr.parent)
91
+ # -> JoinExpr(ParamExpr, LiteralExpr("a")) -- the "b" is gone structurally
92
+
93
+ # Suffix chain collapse
94
+ double_suffix = (root / "file.txt").with_suffix(".tmp").with_suffix(".json")
95
+ print("Suffix chain:", double_suffix)
96
+ # -> WithSuffixExpr(base=JoinExpr(...), suffix='.json') -- .tmp is gone
97
+
98
+ # Connectivity: rebasing
99
+ config = RelativePath(
100
+ base=root,
101
+ relative=P("config") / "settings.yaml"
102
+ )
103
+ print("Config path:", config.resolve({"root": "/project"}))
104
+ # -> /project/config/settings.yaml
105
+
106
+ # Rebase to test environment
107
+ test_config = config.rebase(P("/tmp/test"))
108
+ print("Test config:", test_config.resolve({}))
109
+ # -> /tmp/test/config/settings.yaml
110
+ ```
111
+
112
+ ## Contributing
113
+
114
+ Contributions are welcome! Please:
115
+
116
+ 1. Open an issue to discuss bugs or feature requests
117
+ 2. Fork the repo and submit a PR for changes
118
+ 3. Install dev dependencies with `uv sync` and run `pre-commit install`
119
+
120
+ ## License
121
+
122
+ MIT License - see [LICENSE](LICENSE) for details.
wend-0.1.0/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # wend
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/wend.svg)](https://pypi.org/project/wend/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/wend.svg)](https://pypi.org/project/wend/)
5
+ [![License](https://img.shields.io/pypi/l/wend.svg)](https://github.com/lmmx/wend/blob/master/LICENSE)
6
+ [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/lmmx/wend/master.svg)](https://results.pre-commit.ci/latest/github/lmmx/wend/master)
7
+
8
+ A library for templated paths using Python t-strings (3.14+)
9
+
10
+ ## Installation
11
+
12
+ ```sh
13
+ uv pip install wend
14
+ ````
15
+
16
+ ## Requirements
17
+
18
+ - Python 3.14+
19
+ - Git (with sparse-checkout support)
20
+
21
+ ## About
22
+
23
+ For backstory read my blog about this package [cog.spin.systems/future-paths][blog]!
24
+
25
+ [blog]: https://cog.spin.systems/future-paths
26
+
27
+ The general idea is to use t-strings to house eagerly instantiated parameter objects which offer a
28
+ form of deferred execution.
29
+
30
+ ## Usage
31
+
32
+ Following the examples in the [blog post][blog]:
33
+
34
+ ```py
35
+ from pathlib import Path
36
+ from wend import Param, P, T, RelativePath
37
+
38
+ # Define parameters (late-bound)
39
+ root = Param("root")
40
+ dataset = Param("dataset")
41
+ idx = Param("idx")
42
+ total = Param("total")
43
+
44
+ # Build expressions - these don't touch the filesystem
45
+ data_dir = root / "data" / dataset
46
+ chunk_file = data_dir / T(t"chunk_{idx:04d}-of-{total:04d}.parquet")
47
+
48
+ # Inspect what's needed
49
+ print("Required params:", chunk_file.required_params())
50
+ # -> {'root', 'dataset', 'idx', 'total'}
51
+
52
+ # Resolve with bindings
53
+ path = chunk_file.resolve({
54
+ "root": "/mnt/storage",
55
+ "dataset": "train",
56
+ "idx": 7,
57
+ "total": 100,
58
+ })
59
+ print("Resolved:", path)
60
+ # -> /mnt/storage/data/train/chunk_0007-of-0100.parquet
61
+
62
+ # Constant folding happens at construction
63
+ folded = P("/home") / "user" / "data"
64
+ print("Folded type:", type(folded).__name__)
65
+ # -> LiteralExpr (not nested JoinExprs)
66
+
67
+ # Parent simplification
68
+ expr = root / "a" / "b"
69
+ print("Parent of join:", expr.parent)
70
+ # -> JoinExpr(ParamExpr, LiteralExpr("a")) -- the "b" is gone structurally
71
+
72
+ # Suffix chain collapse
73
+ double_suffix = (root / "file.txt").with_suffix(".tmp").with_suffix(".json")
74
+ print("Suffix chain:", double_suffix)
75
+ # -> WithSuffixExpr(base=JoinExpr(...), suffix='.json') -- .tmp is gone
76
+
77
+ # Connectivity: rebasing
78
+ config = RelativePath(
79
+ base=root,
80
+ relative=P("config") / "settings.yaml"
81
+ )
82
+ print("Config path:", config.resolve({"root": "/project"}))
83
+ # -> /project/config/settings.yaml
84
+
85
+ # Rebase to test environment
86
+ test_config = config.rebase(P("/tmp/test"))
87
+ print("Test config:", test_config.resolve({}))
88
+ # -> /tmp/test/config/settings.yaml
89
+ ```
90
+
91
+ ## Contributing
92
+
93
+ Contributions are welcome! Please:
94
+
95
+ 1. Open an issue to discuss bugs or feature requests
96
+ 2. Fork the repo and submit a PR for changes
97
+ 3. Install dev dependencies with `uv sync` and run `pre-commit install`
98
+
99
+ ## License
100
+
101
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = ["uv_build>=0.9.26,<0.10.0"]
4
+
5
+ [dependency-groups]
6
+ test = [
7
+ "pytest>=8.4.0",
8
+ ]
9
+
10
+ [project]
11
+ name = "wend"
12
+ version = "0.1.0"
13
+ description = "Templated paths using t-strings"
14
+ readme = "README.md"
15
+ authors = [
16
+ {email = "louismmx@gmail.com", name = "Louis Maddox"},
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Natural Language :: English",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Programming Language :: Python :: Implementation :: CPython",
27
+ ]
28
+ keywords = [
29
+ "paths",
30
+ "t-strings",
31
+ ]
32
+ requires-python = ">=3.14"
33
+ dependencies = []
34
+
35
+ [project.license]
36
+ text = "MIT"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/lmmx/wend"
40
+ Repository = "https://github.com/lmmx/wend.git"
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = "tests"
44
+ addopts = "-v"
45
+
46
+ [tool.ruff]
47
+ target-version = "py314"
48
+
49
+ [tool.ruff.lint]
50
+ ignore = ["C408", "C901", "E501", "E722", "E741"]
51
+ select = ["C", "E", "F", "I", "UP", "W"]
52
+
53
+ [tool.ruff.lint.isort]
54
+ known-first-party = ["wend"]
55
+ lines-after-imports = 2
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "__init__.py" = ["E401", "F401"]
59
+
60
+ [tool.uv]
61
+ default-groups = ["test"]
@@ -0,0 +1,291 @@
1
+ """
2
+ Deferred path expressions with late binding.
3
+
4
+ Build path expressions symbolically, resolve them later with concrete bindings.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from string.templatelib import Template
12
+ from typing import Any
13
+
14
+
15
+ # Parameters (late binding slots)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Param:
20
+ """A named slot to be filled at resolution time."""
21
+
22
+ name: str
23
+
24
+ def __truediv__(self, other: PathExpr | str | Param) -> PathExpr:
25
+ if isinstance(other, Param):
26
+ return ParamExpr(self) / ParamExpr(other)
27
+ return ParamExpr(self) / other
28
+
29
+ def __rtruediv__(self, other: str) -> PathExpr:
30
+ return LiteralExpr(other) / ParamExpr(self)
31
+
32
+ def __repr__(self) -> str:
33
+ return f"Param({self.name!r})"
34
+
35
+
36
+ # Path Expressions (composable, deferred)
37
+
38
+
39
+ class PathExpr:
40
+ """Base class for path expressions. Everything is deferred."""
41
+
42
+ def __truediv__(self, other: PathExpr | str | Param) -> PathExpr:
43
+ if isinstance(other, Param):
44
+ right = ParamExpr(other)
45
+ elif isinstance(other, PathExpr):
46
+ right = other
47
+ else:
48
+ right = LiteralExpr(other)
49
+ return JoinExpr.create(self, right)
50
+
51
+ def __rtruediv__(self, other: str) -> PathExpr:
52
+ return JoinExpr.create(LiteralExpr(other), self)
53
+
54
+ @property
55
+ def parent(self) -> PathExpr:
56
+ return ParentExpr.create(self)
57
+
58
+ def with_name(self, name: str) -> PathExpr:
59
+ return WithNameExpr(self, name)
60
+
61
+ def with_suffix(self, suffix: str) -> PathExpr:
62
+ return WithSuffixExpr.create(self, suffix)
63
+
64
+ def required_params(self) -> set[str]:
65
+ """Return all parameter names needed to resolve this expression."""
66
+ raise NotImplementedError
67
+
68
+ def resolve(self, bindings: dict[str, Any] | None = None) -> Path:
69
+ """Resolve to a concrete Path with the given bindings."""
70
+ bindings = bindings or {}
71
+ missing = self.required_params() - set(bindings.keys())
72
+ if missing:
73
+ raise ValueError(f"Missing bindings: {missing}")
74
+ cache: dict[int, Path] = {}
75
+ return self._resolve(bindings, cache)
76
+
77
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
78
+ """Internal resolve with memoization cache."""
79
+ raise NotImplementedError
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class LiteralExpr(PathExpr):
84
+ """A concrete string/path segment."""
85
+
86
+ value: str | Path
87
+
88
+ def required_params(self) -> set[str]:
89
+ return set()
90
+
91
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
92
+ if id(self) in cache:
93
+ return cache[id(self)]
94
+ result = Path(self.value)
95
+ cache[id(self)] = result
96
+ return result
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class ParamExpr(PathExpr):
101
+ """A parameter reference—resolved from bindings."""
102
+
103
+ param: Param
104
+
105
+ def required_params(self) -> set[str]:
106
+ return {self.param.name}
107
+
108
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
109
+ if id(self) in cache:
110
+ return cache[id(self)]
111
+ result = Path(bindings[self.param.name])
112
+ cache[id(self)] = result
113
+ return result
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class TemplateExpr(PathExpr):
118
+ """A t-string template with interpolations."""
119
+
120
+ template: Template
121
+ _params: frozenset[str] = field(init=False, repr=False)
122
+
123
+ def __post_init__(self) -> None:
124
+ params = set()
125
+ for interp in self.template.interpolations:
126
+ if isinstance(interp.value, Param):
127
+ params.add(interp.value.name)
128
+ object.__setattr__(self, "_params", frozenset(params))
129
+
130
+ def required_params(self) -> set[str]:
131
+ return set(self._params)
132
+
133
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
134
+ if id(self) in cache:
135
+ return cache[id(self)]
136
+ parts = []
137
+ for item in self.template:
138
+ if isinstance(item, str):
139
+ parts.append(item)
140
+ else:
141
+ value = item.value
142
+ if isinstance(value, Param):
143
+ value = bindings[value.name]
144
+ parts.append(format(value, item.format_spec))
145
+ result = Path("".join(parts))
146
+ cache[id(self)] = result
147
+ return result
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class JoinExpr(PathExpr):
152
+ """Path join: left / right"""
153
+
154
+ left: PathExpr
155
+ right: PathExpr
156
+
157
+ @staticmethod
158
+ def create(left: PathExpr, right: PathExpr) -> PathExpr:
159
+ """Construct with constant folding."""
160
+ # Fold Literal / Literal -> Literal
161
+ if isinstance(left, LiteralExpr) and isinstance(right, LiteralExpr):
162
+ return LiteralExpr(Path(left.value) / right.value)
163
+ return JoinExpr(left, right)
164
+
165
+ def required_params(self) -> set[str]:
166
+ return self.left.required_params() | self.right.required_params()
167
+
168
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
169
+ if id(self) in cache:
170
+ return cache[id(self)]
171
+ result = self.left._resolve(bindings, cache) / self.right._resolve(
172
+ bindings, cache
173
+ )
174
+ cache[id(self)] = result
175
+ return result
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class ParentExpr(PathExpr):
180
+ """Parent of a path expression."""
181
+
182
+ child: PathExpr
183
+
184
+ @staticmethod
185
+ def create(child: PathExpr) -> PathExpr:
186
+ """Construct with algebraic simplification."""
187
+ # parent(a / b) = a (when b is single component)
188
+ if isinstance(child, JoinExpr):
189
+ # Check if right side is a single component (no further joins)
190
+ if isinstance(child.right, (LiteralExpr, ParamExpr, TemplateExpr)):
191
+ return child.left
192
+ # parent(Literal) can be folded
193
+ if isinstance(child, LiteralExpr):
194
+ return LiteralExpr(Path(child.value).parent)
195
+ return ParentExpr(child)
196
+
197
+ def required_params(self) -> set[str]:
198
+ return self.child.required_params()
199
+
200
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
201
+ if id(self) in cache:
202
+ return cache[id(self)]
203
+ result = self.child._resolve(bindings, cache).parent
204
+ cache[id(self)] = result
205
+ return result
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class WithNameExpr(PathExpr):
210
+ """Replace the final component name."""
211
+
212
+ base: PathExpr
213
+ name: str
214
+
215
+ def required_params(self) -> set[str]:
216
+ return self.base.required_params()
217
+
218
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
219
+ if id(self) in cache:
220
+ return cache[id(self)]
221
+ result = self.base._resolve(bindings, cache).with_name(self.name)
222
+ cache[id(self)] = result
223
+ return result
224
+
225
+
226
+ @dataclass(frozen=True)
227
+ class WithSuffixExpr(PathExpr):
228
+ """Change suffix of a path."""
229
+
230
+ base: PathExpr
231
+ suffix: str
232
+
233
+ @staticmethod
234
+ def create(base: PathExpr, suffix: str) -> PathExpr:
235
+ """Construct with suffix chain collapse."""
236
+ # path.with_suffix(".a").with_suffix(".b") -> path.with_suffix(".b")
237
+ if isinstance(base, WithSuffixExpr):
238
+ return WithSuffixExpr(base.base, suffix)
239
+ # Literal can be folded
240
+ if isinstance(base, LiteralExpr):
241
+ return LiteralExpr(Path(base.value).with_suffix(suffix))
242
+ return WithSuffixExpr(base, suffix)
243
+
244
+ def required_params(self) -> set[str]:
245
+ return self.base.required_params()
246
+
247
+ def _resolve(self, bindings: dict[str, Any], cache: dict[int, Path]) -> Path:
248
+ if id(self) in cache:
249
+ return cache[id(self)]
250
+ result = self.base._resolve(bindings, cache).with_suffix(self.suffix)
251
+ cache[id(self)] = result
252
+ return result
253
+
254
+
255
+ # Convenience constructors
256
+
257
+
258
+ def P(path: str | Path) -> LiteralExpr:
259
+ """Literal path expression."""
260
+ return LiteralExpr(path)
261
+
262
+
263
+ def T(template: Template) -> TemplateExpr:
264
+ """Template path expression from a t-string."""
265
+ return TemplateExpr(template)
266
+
267
+
268
+ # Connectivity (rebasing)
269
+
270
+
271
+ @dataclass(frozen=True)
272
+ class RelativePath:
273
+ """A path defined relative to a base, preserving the relation."""
274
+
275
+ base: PathExpr
276
+ relative: PathExpr
277
+
278
+ @property
279
+ def expr(self) -> PathExpr:
280
+ """The full path expression."""
281
+ return self.base / self.relative
282
+
283
+ def rebase(self, new_base: PathExpr) -> RelativePath:
284
+ """Create equivalent path under a different base."""
285
+ return RelativePath(new_base, self.relative)
286
+
287
+ def resolve(self, bindings: dict[str, Any] | None = None) -> Path:
288
+ return self.expr.resolve(bindings)
289
+
290
+ def required_params(self) -> set[str]:
291
+ return self.expr.required_params()