samplekit 1.0.0rc2__cp315-cp315-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,405 @@
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
+ steel = sk.load("samples/steel-01.md")
10
+ steel.thickness.readings = [1.991, 1.994, 1.989]
11
+ steel.compute()
12
+ steel.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 ``04-results`` folder, the
18
+ tensile test of the documentation's home page, after::
19
+
20
+ import samplekit as sk
21
+
22
+ specimens = sk.load("samples")
23
+ steel = specimens["steel-01"]
24
+ """
25
+
26
+ # This file is the package's Python, and it writes only what the native module
27
+ # cannot: BoundList, a real list bound to a sample, and the class of Sample,
28
+ # which loads a file after the class's __init__ returns. Every rule either
29
+ # applies is a call into samplekit._native.
30
+
31
+
32
+ class NotApplicable:
33
+ """A value that does not apply to a sample — ``yield_strength: n/a`` in its file.
34
+
35
+ There is one, ``samplekit.NA``. It is false in a test and prints as
36
+ ``n/a``; a property set to it is saved as ``n/a``, and a formula reading
37
+ it gives ``NA`` too, without running.
38
+
39
+ Example:
40
+ >>> steel.yield_strength.value = sk.NA
41
+ """
42
+
43
+ _one = None
44
+
45
+ def __new__(cls):
46
+ if cls._one is None:
47
+ cls._one = super().__new__(cls)
48
+ return cls._one
49
+
50
+ def __repr__(self):
51
+ return "samplekit.NA"
52
+
53
+ def __str__(self):
54
+ return "n/a"
55
+
56
+ def __bool__(self):
57
+ return False
58
+
59
+ def __float__(self):
60
+ # NaN, as pandas and numpy read a value that is not there: a number
61
+ # that answers no arithmetic, where `float(NA)` raised.
62
+ return float("nan")
63
+
64
+ def __reduce__(self):
65
+ return (NotApplicable, ())
66
+
67
+
68
+ NA = NotApplicable()
69
+
70
+ from samplekit import _native # noqa: E402
71
+ from samplekit._native import (
72
+ Collection,
73
+ ColumnSpec,
74
+ ColumnView,
75
+ ComputedColumns,
76
+ Export,
77
+ Field,
78
+ FigureDeclaration,
79
+ Model,
80
+ Names,
81
+ Profile,
82
+ Project,
83
+ Property,
84
+ PropertyDeclaration,
85
+ Query,
86
+ Render,
87
+ RowView,
88
+ SampleList,
89
+ Statistic,
90
+ Summary,
91
+ Table,
92
+ Unit,
93
+ )
94
+
95
+ from samplekit._native import load, stats
96
+ from samplekit._figures import figure, plot
97
+ from samplekit._scripts import script
98
+
99
+ __version__ = _native.__version__
100
+
101
+
102
+ def keep(message=None):
103
+ """Keep what the script has saved so far as one snapshot of the history.
104
+
105
+ A script's saves are kept as one snapshot when it ends, with the
106
+ script's source beside it; ``keep`` takes that snapshot now, so that the
107
+ history shows the script's steps apart.
108
+
109
+ Args:
110
+ message: What the snapshot says; the script's command line when not
111
+ given. A blank message says nothing, and several lines are kept
112
+ on one.
113
+
114
+ Warns:
115
+ UserWarning: Nothing was saved in a project since the last snapshot,
116
+ so nothing is kept.
117
+
118
+ Raises:
119
+ TypeError: The message is not text.
120
+
121
+ Example:
122
+ >>> steel.save()
123
+ >>> sk.keep("thickness measured again")
124
+ """
125
+ if message is not None and not isinstance(message, str):
126
+ raise TypeError(f"keep() takes a message as text, not {type(message).__name__}")
127
+ if message is not None:
128
+ # One line: the history's log shows a message a row each.
129
+ message = " ".join(message.split()) or None
130
+ # Nothing kept is said: a call that did nothing, nothing saved since the
131
+ # last snapshot or outside any project, was silent.
132
+ if not _native._history_flush(message):
133
+ import warnings
134
+
135
+ warnings.warn(
136
+ "keep(): nothing was saved in a project since the last snapshot, so nothing "
137
+ "was kept",
138
+ stacklevel=2,
139
+ )
140
+
141
+
142
+ # One snapshot for the whole script, when the interpreter ends.
143
+ import atexit as _atexit # noqa: E402
144
+
145
+ _atexit.register(_native._history_flush)
146
+
147
+
148
+ def _ended_by_signal(number, frame):
149
+ """A script asked to end — SIGTERM, a scheduler stopping it — ends as
150
+ one that returns: its snapshot kept, where it left its writes shown
151
+ *changed outside SampleKit, not yet kept*."""
152
+ raise SystemExit(128 + number)
153
+
154
+
155
+ def _end_on_sigterm():
156
+ import os
157
+ import signal
158
+ import threading
159
+
160
+ # Where nothing else asked for SIGTERM, and Python allows a handler; never
161
+ # in the model's worker, which keeps no history and is stopped so.
162
+ if os.environ.get("SAMPLEKIT_HISTORY") == "off":
163
+ return
164
+ if threading.current_thread() is not threading.main_thread():
165
+ return
166
+ try:
167
+ if signal.getsignal(signal.SIGTERM) is signal.SIG_DFL:
168
+ signal.signal(signal.SIGTERM, _ended_by_signal)
169
+ except (ValueError, OSError, AttributeError):
170
+ pass
171
+
172
+
173
+ _end_on_sigterm()
174
+
175
+ __all__ = [
176
+ "BoundList",
177
+ "Collection",
178
+ "ColumnSpec",
179
+ "ColumnView",
180
+ "ComputedColumns",
181
+ "Export",
182
+ "Field",
183
+ "FigureDeclaration",
184
+ "Model",
185
+ "NA",
186
+ "Names",
187
+ "NotApplicable",
188
+ "Profile",
189
+ "Project",
190
+ "Property",
191
+ "PropertyDeclaration",
192
+ "Query",
193
+ "Render",
194
+ "RowView",
195
+ "Sample",
196
+ "SampleList",
197
+ "Statistic",
198
+ "Summary",
199
+ "Table",
200
+ "Unit",
201
+ "figure",
202
+ "keep",
203
+ "load",
204
+ "plot",
205
+ "script",
206
+ "stats",
207
+ ]
208
+
209
+
210
+ class BoundList(list):
211
+ """A list bound to a sample: its changes are the sample's.
212
+
213
+ A sample's tags, a list attribute and a property's readings are read as
214
+ one. ``append``, ``remove``, an item assigned and every other change is
215
+ checked and made in the sample too, and a refused item leaves both
216
+ unchanged. ``list(...)`` or ``.copy()`` is a plain copy, bound to
217
+ nothing, and so is a list read before the attribute was assigned again.
218
+
219
+ Example:
220
+ >>> steel.tags.append("reference")
221
+ """
222
+
223
+ __slots__ = ("_owner", "_name", "_epoch", "__weakref__")
224
+
225
+ def _change(self, mutate):
226
+ candidate = list(self)
227
+ result = mutate(candidate)
228
+ # Made by hand, `BoundList([1, 2])`, it is bound to nothing, and
229
+ # changes as a list does.
230
+ owner = getattr(self, "_owner", None)
231
+ if owner is None:
232
+ list.__setitem__(self, slice(None), candidate)
233
+ return result
234
+ committed = owner._commit_list(self._name, self._epoch, candidate)
235
+ list.__setitem__(self, slice(None), candidate if committed is None else committed)
236
+ return result
237
+
238
+ def append(self, item):
239
+ self._change(lambda items: items.append(item))
240
+
241
+ def extend(self, items):
242
+ self._change(lambda current: current.extend(items))
243
+
244
+ def insert(self, index, item):
245
+ self._change(lambda items: items.insert(index, item))
246
+
247
+ def pop(self, index=-1):
248
+ return self._change(lambda items: items.pop(index))
249
+
250
+ def remove(self, item):
251
+ self._change(lambda items: items.remove(item))
252
+
253
+ def clear(self):
254
+ self._change(lambda items: items.clear())
255
+
256
+ def sort(self, *, key=None, reverse=False):
257
+ self._change(lambda items: items.sort(key=key, reverse=reverse))
258
+
259
+ def reverse(self):
260
+ self._change(lambda items: items.reverse())
261
+
262
+ def __setitem__(self, index, value):
263
+ self._change(lambda items: items.__setitem__(index, value))
264
+
265
+ def __delitem__(self, index):
266
+ self._change(lambda items: items.__delitem__(index))
267
+
268
+ def __iadd__(self, other):
269
+ self._change(lambda items: items.__iadd__(other))
270
+ return self
271
+
272
+ def __imul__(self, count):
273
+ self._change(lambda items: items.__imul__(count))
274
+ return self
275
+
276
+ def copy(self):
277
+ return list(self)
278
+
279
+ def __reduce_ex__(self, protocol):
280
+ return (list, (list(self),))
281
+
282
+
283
+ def _bind(owner, name, epoch, items):
284
+ bound = BoundList(items)
285
+ bound._owner = owner
286
+ bound._name = name
287
+ bound._epoch = epoch
288
+ return bound
289
+
290
+
291
+ def _arguments(path=None, name=None, model=None):
292
+ return path, name, model
293
+
294
+
295
+ class _Loading(type(_native.Sample)):
296
+ """Constructs a sample, then loads its file.
297
+
298
+ A file is read after the class's ``__init__`` returns, so that the
299
+ ``Property`` a model declares is filled by the file rather than overwriting
300
+ what was read; and ``Sample(path)`` with a project's model constructs that
301
+ model instead. Only a metaclass runs at both moments.
302
+ """
303
+
304
+ def __call__(cls, *args, **kwargs):
305
+ if cls is Sample:
306
+ path, name, model = _arguments(*args, **kwargs)
307
+ chosen = _native._model_for(path, model)
308
+ if chosen is not None:
309
+ sample = chosen(path)
310
+ sample._declare_name(name)
311
+ return sample
312
+ sample = super().__call__(*args, **kwargs)
313
+ sample._load(args, kwargs)
314
+ return sample
315
+
316
+ def __getattr__(cls, name):
317
+ # `name=None`: the message names the replacement, and Python adding a
318
+ # nearest name of its own wrote a second suggestion after it.
319
+ raise AttributeError(_native._missing_class_member(cls, name), name=None)
320
+
321
+
322
+ class Sample(_native.Sample, metaclass=_Loading):
323
+ """One sample, read from and saved to a Markdown file.
324
+
325
+ ``Sample(path)`` reads the file with the model its project declares;
326
+ ``Sample(name=...)`` makes a sample with no file, which ``save(path)``
327
+ gives one. A model is a subclass of ``Sample`` whose ``__init__``
328
+ declares its properties and tables, and passes ``path`` on to
329
+ ``super().__init__``.
330
+
331
+ A property, a table or an attribute is read as an attribute or an item,
332
+ ``steel.area`` or ``steel["area"]``, and any other field path as an item:
333
+ ``steel["curve.force[0.2]"]`` is that cell's value. What is assigned
334
+ decides what a name becomes: a ``Property`` or a ``Table`` is one; a
335
+ ``bool``, ``int``, ``float``, ``str``, ``date``, ``datetime``, or a list
336
+ of one of them, is an attribute; and a name with a leading ``_`` is
337
+ ordinary Python state, never saved. **Nothing assigned without a leading
338
+ ``_`` is lost on save**: it is saved, or refused.
339
+
340
+ Args:
341
+ path: The sample's file.
342
+ name: The sample's name; a name the file holds wins over it.
343
+ model: The class to read the file with, or ``False`` to read the
344
+ data alone. By default, the class the project's ``[model]``
345
+ declares.
346
+
347
+ Raises:
348
+ FileNotFoundError: The file does not exist; the message names the
349
+ nearest. ``Sample.new`` makes a sample for a new file.
350
+ IsADirectoryError: The path is a folder: ``sk.load`` reads a folder.
351
+ ValueError: The file is not a sample.
352
+ AttributeError: An unknown field, read as an attribute; the message
353
+ names the nearest.
354
+ KeyError: An unknown field, read as an item.
355
+ TypeError: A value of a kind that cannot be saved; a leading ``_``
356
+ keeps it in Python only.
357
+
358
+ Example:
359
+ >>> steel = sk.Sample("samples/steel-01.md")
360
+ >>> steel.material
361
+ 'steel'
362
+ >>> steel["curve.force[0.2]"]
363
+ 9.516
364
+ """
365
+
366
+ @classmethod
367
+ def new(cls, path, name=None, model=None):
368
+ """Make a new sample for a file that does not exist yet.
369
+
370
+ The sample is of the model the project of that folder declares — or of
371
+ this class, when called on a model — and is named by the file, which
372
+ writes no ``name:`` unless ``name`` is given.
373
+ ``save()`` writes the file.
374
+
375
+ Args:
376
+ path: The file to create.
377
+ name: The sample's name, instead of the file's.
378
+ model: The class to make it with, or ``False`` for a plain
379
+ ``Sample``.
380
+
381
+ Returns:
382
+ The new sample, not saved yet.
383
+
384
+ Raises:
385
+ FileExistsError: The file exists already: ``Sample(path)`` reads it.
386
+ FileNotFoundError: The folder does not exist.
387
+
388
+ Example:
389
+ >>> specimen = sk.Sample.new("samples/steel-02.md")
390
+ >>> specimen.material = "steel"
391
+ >>> specimen.save()
392
+ """
393
+ _native._check_new(path)
394
+ chosen = cls
395
+ if cls is Sample:
396
+ chosen = _native._model_for(path, model) or Sample
397
+ sample = chosen()
398
+ sample._create_at(path)
399
+ # Named by its file, and writing no `name:` unless one is given.
400
+ if name is not None:
401
+ sample._declare_name(name)
402
+ return sample
403
+
404
+
405
+ _native._register(Sample, _bind)