samplekit 1.0.0rc1__cp313-cp313-win_amd64.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.
samplekit/__init__.py ADDED
@@ -0,0 +1,350 @@
1
+ """SampleKit: sample data in plain Markdown — computed, current, tracked.
2
+
3
+ A sample is a Markdown file whose frontmatter holds its values. In Python a
4
+ sample is read from its file, read and changed through its attributes, and
5
+ saved back::
6
+
7
+ import samplekit as sk
8
+
9
+ brew = sk.load("brews/citra-ipa.md")
10
+ brew.volume.value = 21
11
+ brew.compute()
12
+ brew.save()
13
+
14
+ ``load`` reads a folder as a ``SampleList``, which filters, sorts, groups and
15
+ exports as the command line does.
16
+
17
+ The examples of this package run in the demo's ``08-python`` folder, after::
18
+
19
+ import samplekit as sk
20
+
21
+ brews = sk.load("brews")
22
+ ipa = brews["citra-ipa"]
23
+ """
24
+
25
+ # This file is the package's Python, and it writes only what the native module
26
+ # cannot: BoundList, a real list bound to a sample, and the class of Sample,
27
+ # which loads a file after the class's __init__ returns. Every rule either
28
+ # applies is a call into samplekit._native.
29
+
30
+
31
+ class NotApplicable:
32
+ """A value that does not apply to a sample: ``fg: n/a`` in its file.
33
+
34
+ There is one, ``samplekit.NA``. It is false in a test and prints as
35
+ ``n/a``; a property set to it is saved as ``n/a``, and a formula reading
36
+ it gives ``NA`` too, without running.
37
+
38
+ Example:
39
+ >>> ipa.fg.value = sk.NA
40
+ """
41
+
42
+ _one = None
43
+
44
+ def __new__(cls):
45
+ if cls._one is None:
46
+ cls._one = super().__new__(cls)
47
+ return cls._one
48
+
49
+ def __repr__(self):
50
+ return "samplekit.NA"
51
+
52
+ def __str__(self):
53
+ return "n/a"
54
+
55
+ def __bool__(self):
56
+ return False
57
+
58
+ def __reduce__(self):
59
+ return (NotApplicable, ())
60
+
61
+
62
+ NA = NotApplicable()
63
+
64
+ from samplekit import _native # noqa: E402
65
+ from samplekit._native import (
66
+ Collection,
67
+ Column,
68
+ ColumnSpec,
69
+ ColumnView,
70
+ Export,
71
+ Field,
72
+ FigureDeclaration,
73
+ Model,
74
+ Names,
75
+ Profile,
76
+ Project,
77
+ Property,
78
+ PropertyDeclaration,
79
+ Query,
80
+ Render,
81
+ RowView,
82
+ SampleList,
83
+ Statistic,
84
+ Summary,
85
+ Table,
86
+ Unit,
87
+ )
88
+
89
+ from samplekit._native import load, stats
90
+ from samplekit._figures import figure, plot
91
+
92
+ __version__ = _native.__version__
93
+
94
+
95
+ def keep(message=None):
96
+ """Keep what the script has saved so far as one snapshot of the history.
97
+
98
+ A script's saves are kept as one snapshot when it ends, with the
99
+ script's source beside it; ``keep`` takes that snapshot now, so that the
100
+ history shows the script's steps apart.
101
+
102
+ Args:
103
+ message: What the snapshot says; the script's command line when not
104
+ given. A blank message says nothing, and several lines are kept
105
+ on one.
106
+
107
+ Raises:
108
+ TypeError: The message is not text.
109
+
110
+ Example:
111
+ >>> ipa.save()
112
+ >>> sk.keep("volumes corrected")
113
+ """
114
+ if message is not None and not isinstance(message, str):
115
+ raise TypeError(f"keep() takes a message as text, not {type(message).__name__}")
116
+ if message is not None:
117
+ # One line: the history's log shows a message a row each.
118
+ message = " ".join(message.split()) or None
119
+ _native._history_flush(message)
120
+
121
+
122
+ # One snapshot for the whole script, when the interpreter ends.
123
+ import atexit as _atexit # noqa: E402
124
+
125
+ _atexit.register(_native._history_flush)
126
+
127
+ __all__ = [
128
+ "BoundList",
129
+ "Collection",
130
+ "Column",
131
+ "ColumnSpec",
132
+ "ColumnView",
133
+ "Export",
134
+ "Field",
135
+ "FigureDeclaration",
136
+ "Model",
137
+ "NA",
138
+ "Names",
139
+ "NotApplicable",
140
+ "Profile",
141
+ "Project",
142
+ "Property",
143
+ "PropertyDeclaration",
144
+ "Query",
145
+ "Render",
146
+ "RowView",
147
+ "Sample",
148
+ "SampleList",
149
+ "Statistic",
150
+ "Summary",
151
+ "Table",
152
+ "Unit",
153
+ "figure",
154
+ "keep",
155
+ "load",
156
+ "plot",
157
+ "stats",
158
+ ]
159
+
160
+
161
+ class BoundList(list):
162
+ """A list bound to a sample: its changes are the sample's.
163
+
164
+ A sample's tags, a list attribute and a property's readings are read as
165
+ one. ``append``, ``remove``, an item assigned and every other change is
166
+ checked and made in the sample too, and a refused item leaves both
167
+ unchanged. ``list(...)`` or ``.copy()`` is a plain copy, bound to
168
+ nothing, and so is a list read before the attribute was assigned again.
169
+
170
+ Example:
171
+ >>> ipa.tags.append("medal")
172
+ """
173
+
174
+ __slots__ = ("_owner", "_name", "_epoch", "__weakref__")
175
+
176
+ def _change(self, mutate):
177
+ candidate = list(self)
178
+ result = mutate(candidate)
179
+ committed = self._owner._commit_list(self._name, self._epoch, candidate)
180
+ list.__setitem__(self, slice(None), candidate if committed is None else committed)
181
+ return result
182
+
183
+ def append(self, item):
184
+ self._change(lambda items: items.append(item))
185
+
186
+ def extend(self, items):
187
+ self._change(lambda current: current.extend(items))
188
+
189
+ def insert(self, index, item):
190
+ self._change(lambda items: items.insert(index, item))
191
+
192
+ def pop(self, index=-1):
193
+ return self._change(lambda items: items.pop(index))
194
+
195
+ def remove(self, item):
196
+ self._change(lambda items: items.remove(item))
197
+
198
+ def clear(self):
199
+ self._change(lambda items: items.clear())
200
+
201
+ def sort(self, *, key=None, reverse=False):
202
+ self._change(lambda items: items.sort(key=key, reverse=reverse))
203
+
204
+ def reverse(self):
205
+ self._change(lambda items: items.reverse())
206
+
207
+ def __setitem__(self, index, value):
208
+ self._change(lambda items: items.__setitem__(index, value))
209
+
210
+ def __delitem__(self, index):
211
+ self._change(lambda items: items.__delitem__(index))
212
+
213
+ def __iadd__(self, other):
214
+ self._change(lambda items: items.__iadd__(other))
215
+ return self
216
+
217
+ def __imul__(self, count):
218
+ self._change(lambda items: items.__imul__(count))
219
+ return self
220
+
221
+ def copy(self):
222
+ return list(self)
223
+
224
+ def __reduce_ex__(self, protocol):
225
+ return (list, (list(self),))
226
+
227
+
228
+ def _bind(owner, name, epoch, items):
229
+ bound = BoundList(items)
230
+ bound._owner = owner
231
+ bound._name = name
232
+ bound._epoch = epoch
233
+ return bound
234
+
235
+
236
+ def _arguments(path=None, name=None, model=None):
237
+ return path, name, model
238
+
239
+
240
+ class _Loading(type(_native.Sample)):
241
+ """Constructs a sample, then loads its file.
242
+
243
+ A file is read after the class's ``__init__`` returns, so that the
244
+ ``Property`` a model declares is filled by the file rather than overwriting
245
+ what was read; and ``Sample(path)`` with a project's model constructs that
246
+ model instead. Only a metaclass runs at both moments.
247
+ """
248
+
249
+ def __call__(cls, *args, **kwargs):
250
+ if cls is Sample:
251
+ path, name, model = _arguments(*args, **kwargs)
252
+ chosen = _native._model_for(path, model)
253
+ if chosen is not None:
254
+ sample = chosen(path)
255
+ sample._declare_name(name)
256
+ return sample
257
+ sample = super().__call__(*args, **kwargs)
258
+ sample._load(args, kwargs)
259
+ return sample
260
+
261
+ def __getattr__(cls, name):
262
+ # `name=None`: the message names the replacement, and Python adding a
263
+ # nearest name of its own wrote a second suggestion after it.
264
+ raise AttributeError(_native._missing_class_member(cls, name), name=None)
265
+
266
+
267
+ class Sample(_native.Sample, metaclass=_Loading):
268
+ """One sample, read from and saved to a Markdown file.
269
+
270
+ ``Sample(path)`` reads the file with the model its project declares;
271
+ ``Sample(name=...)`` makes a sample with no file, which ``save(path)``
272
+ gives one. A model is a subclass of ``Sample`` whose ``__init__``
273
+ declares its properties and tables, and passes ``path`` on to
274
+ ``super().__init__``.
275
+
276
+ A property, a table or an attribute is read as an attribute or an item,
277
+ ``brew.abv`` or ``brew["abv"]``, and any other field path as an item:
278
+ ``brew["fermentation.gravity[2]"]`` is that cell's value. What is assigned
279
+ decides what a name becomes: a ``Property`` or a ``Table`` is one; a
280
+ ``bool``, ``int``, ``float``, ``str``, ``date``, ``datetime``, or a list
281
+ of one of them, is an attribute; and a name with a leading ``_`` is
282
+ ordinary Python state, never saved. **Nothing assigned without a leading
283
+ ``_`` is lost on save**: it is saved, or refused.
284
+
285
+ Args:
286
+ path: The sample's file.
287
+ name: The sample's name; a name the file holds wins over it.
288
+ model: The class to read the file with, or ``False`` to read the
289
+ data alone. By default, the class the project's ``[model]``
290
+ declares.
291
+
292
+ Raises:
293
+ FileNotFoundError: The file does not exist; the message names the
294
+ nearest. ``Sample.new`` makes a sample for a new file.
295
+ IsADirectoryError: The path is a folder: ``sk.load`` reads a folder.
296
+ ValueError: The file is not a sample.
297
+ AttributeError: An unknown field, read as an attribute; the message
298
+ names the nearest.
299
+ KeyError: An unknown field, read as an item.
300
+ TypeError: A value of a kind that cannot be saved; a leading ``_``
301
+ keeps it in Python only.
302
+
303
+ Example:
304
+ >>> brew = sk.Sample("brews/citra-ipa.md")
305
+ >>> brew.style
306
+ 'ipa'
307
+ >>> brew["fermentation.gravity[2]"]
308
+ 1.029
309
+ """
310
+
311
+ @classmethod
312
+ def new(cls, path, name=None, model=None):
313
+ """Make a new sample for a file that does not exist yet.
314
+
315
+ The sample is of the model the project of that folder declares — or of
316
+ this class, when called on a model — and is named by the file, which
317
+ writes no ``name:`` unless ``name`` is given.
318
+ ``save()`` writes the file.
319
+
320
+ Args:
321
+ path: The file to create.
322
+ name: The sample's name, instead of the file's.
323
+ model: The class to make it with, or ``False`` for a plain
324
+ ``Sample``.
325
+
326
+ Returns:
327
+ The new sample, not saved yet.
328
+
329
+ Raises:
330
+ FileExistsError: The file exists already: ``Sample(path)`` reads it.
331
+ FileNotFoundError: The folder does not exist.
332
+
333
+ Example:
334
+ >>> brew = sk.Sample.new("brews/red-ale.md")
335
+ >>> brew.style = "red_ale"
336
+ >>> brew.save()
337
+ """
338
+ _native._check_new(path)
339
+ chosen = cls
340
+ if cls is Sample:
341
+ chosen = _native._model_for(path, model) or Sample
342
+ sample = chosen()
343
+ sample._create_at(path)
344
+ # Named by its file, and writing no `name:` unless one is given.
345
+ if name is not None:
346
+ sample._declare_name(name)
347
+ return sample
348
+
349
+
350
+ _native._register(Sample, _bind)