serif 0.0.1__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.
jib/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """
2
+ jib: A Pythonic, zero-dependency vector and table library
3
+
4
+ Designed for Python users who need to work with datasets beyond Excel's limits
5
+ (>1000 rows) but want the ease-of-use and intuitive feel of Excel or SQL.
6
+
7
+ Main classes:
8
+ - Vector: 1D vector with optional type safety
9
+ - Table: 2D table (multiple columns of equal length)
10
+
11
+ Type-specific subclasses (auto-created):
12
+ - _Float: Vector of floats with float method proxying
13
+ - _Int: Vector of integers with int method proxying
14
+ - _String: Vector of strings with string method proxying
15
+ - _Date: Vector of dates with date method proxying
16
+
17
+ Zero external dependencies - pure Python stdlib only.
18
+ """
19
+
20
+ from .alias_tracker import _ALIAS_TRACKER, AliasError
21
+ from .vector import Vector, _Float, _Int, _String, _Date
22
+ from .table import Table
23
+ from .errors import JibError, JibKeyError, JibValueError, JibTypeError, JibIndexError
24
+ from .csv import read_csv
25
+ from .typing import DataType
26
+
27
+ __version__ = "0.0.1"
28
+ __all__ = [
29
+ "Vector",
30
+ "Table",
31
+ "read_csv",
32
+ "DataType",
33
+ "AliasError",
34
+ "JibError",
35
+ "JibKeyError",
36
+ "JibValueError",
37
+ "JibTypeError",
38
+ "JibIndexError"
39
+ ]
jib/alias_tracker.py ADDED
@@ -0,0 +1,103 @@
1
+ # py_vector/_alias_tracker.py
2
+
3
+ import weakref
4
+
5
+
6
+ class AliasError(Exception):
7
+ pass
8
+
9
+
10
+ class _AliasTracker:
11
+ """
12
+ Tracks which Vector instances reference the same underlying tuple.
13
+
14
+ Key design points:
15
+ - Uses id(tuple) → list[weakref.ref(Vector)]
16
+ - No hashing of Vector objects (they remain unhashable)
17
+ - No use of WeakSet (because that requires hashing)
18
+ - Pure identity tracking
19
+ - Auto-prunes dead references
20
+ """
21
+
22
+ def __init__(self):
23
+ # tuple_id -> list of weakrefs to Vectors using that tuple
24
+ self._registry = {}
25
+
26
+ def _cleanup_dead_refs(self, refs):
27
+ """Remove any weakrefs that no longer point to a live object."""
28
+ return [r for r in refs if r() is not None]
29
+
30
+ def register(self, vec, tuple_id):
31
+ """
32
+ Register a Vector as sharing the tuple with key tuple_id.
33
+ """
34
+ refs = self._registry.setdefault(tuple_id, [])
35
+
36
+ # clean dead refs before adding
37
+ refs = self._cleanup_dead_refs(refs)
38
+
39
+ # Check if vec is already registered for this tuple_id
40
+ for r in refs:
41
+ if r() is vec:
42
+ # Already registered, don't add again
43
+ return
44
+
45
+ # store the cleaned list back
46
+ self._registry[tuple_id] = refs
47
+
48
+ # we always register by weakref, no hashing required
49
+ refs.append(weakref.ref(vec))
50
+
51
+ def unregister(self, vec, tuple_id):
52
+ """
53
+ Remove a Vector from the registry for a given tuple_id.
54
+ If that tuple_id has no remaining owners, delete the entry.
55
+ """
56
+ refs = self._registry.get(tuple_id)
57
+ if not refs:
58
+ return
59
+
60
+ # Remove matching weakref
61
+ alive = []
62
+ for r in refs:
63
+ obj = r()
64
+ if obj is None:
65
+ continue
66
+ if obj is vec:
67
+ continue
68
+ alive.append(r)
69
+
70
+ if alive:
71
+ self._registry[tuple_id] = alive
72
+ else:
73
+ del self._registry[tuple_id]
74
+
75
+ def check_writable(self, vec, tuple_id):
76
+ """
77
+ Returns True if vec is the *only* owner of tuple_id.
78
+ Otherwise raises AliasError.
79
+ """
80
+ refs = self._registry.get(tuple_id)
81
+ if not refs:
82
+ return True # nothing registered → writable
83
+
84
+ # drop dead weakrefs
85
+ alive = self._cleanup_dead_refs(refs)
86
+ self._registry[tuple_id] = alive
87
+
88
+ # Count how many Vectors still alive share this tuple
89
+ owners = [r() for r in alive if r() is not None]
90
+
91
+ if len(owners) <= 1:
92
+ return True
93
+
94
+ # >1 owner → alias detected
95
+ raise AliasError(
96
+ "This Vector shares underlying storage with another.\n"
97
+ "Use .copy() to create an independent, writable vector."
98
+ )
99
+
100
+
101
+ # THE SINGLETON (imported by Vector)
102
+ _ALIAS_TRACKER = _AliasTracker()
103
+
jib/csv.py ADDED
@@ -0,0 +1,115 @@
1
+ """CSV reading utilities for Vector/Table."""
2
+
3
+ import csv
4
+ from typing import TextIO
5
+
6
+
7
+ def read_csv(file, *, delimiter=',', has_header=True, encoding='utf-8'):
8
+ """
9
+ Read a CSV file and return a Table.
10
+
11
+ Parameters
12
+ ----------
13
+ file : str or file-like
14
+ Path to CSV file or file-like object
15
+ delimiter : str, default ','
16
+ Field delimiter
17
+ has_header : bool, default True
18
+ Whether first row contains column names
19
+ encoding : str, default 'utf-8'
20
+ File encoding (used only if file is a path)
21
+
22
+ Returns
23
+ -------
24
+ Table
25
+ Table with columns from the CSV file
26
+
27
+ Examples
28
+ --------
29
+ >>> t = read_csv("data.csv")
30
+ >>> t = read_csv("data.tsv", delimiter='\\t')
31
+ >>> with open("data.csv") as f:
32
+ ... t = read_csv(f)
33
+ """
34
+ from .table import Table
35
+ from .vector import Vector
36
+
37
+ # Handle file path vs file object
38
+ if isinstance(file, str):
39
+ with open(file, 'r', encoding=encoding, newline='') as f:
40
+ return _read_csv_from_file(f, delimiter=delimiter, has_header=has_header)
41
+ else:
42
+ return _read_csv_from_file(file, delimiter=delimiter, has_header=has_header)
43
+
44
+
45
+ def _read_csv_from_file(file_obj: TextIO, *, delimiter: str, has_header: bool):
46
+ """Read CSV data from an open file object."""
47
+ from .table import Table
48
+ from .vector import Vector
49
+
50
+ reader = csv.reader(file_obj, delimiter=delimiter)
51
+
52
+ # Read all rows first
53
+ all_rows = list(reader)
54
+
55
+ if not all_rows:
56
+ return Table()
57
+
58
+ # Determine header and data rows
59
+ if has_header:
60
+ header = all_rows[0]
61
+ rows = all_rows[1:]
62
+ else:
63
+ # Generate default column names: col_0, col_1, etc.
64
+ header = [f"col_{i}" for i in range(len(all_rows[0]))]
65
+ rows = all_rows
66
+
67
+ if not rows:
68
+ # Header only, no data
69
+ return Table({col: Vector() for col in header})
70
+
71
+ # Transpose rows into columns
72
+ num_cols = len(header)
73
+ columns = []
74
+
75
+ for col_idx in range(num_cols):
76
+ column_data = []
77
+ for row in rows:
78
+ # Handle jagged rows
79
+ if col_idx < len(row):
80
+ value = row[col_idx]
81
+ # Try to infer type
82
+ column_data.append(_infer_type(value))
83
+ else:
84
+ column_data.append(None)
85
+ columns.append(Vector(column_data, name=header[col_idx]))
86
+
87
+ return Table(columns)
88
+
89
+
90
+ def _infer_type(value: str):
91
+ """
92
+ Attempt to convert string value to int, float, or leave as string.
93
+
94
+ Returns None for empty strings.
95
+ """
96
+ if not value or value.strip() == '':
97
+ return None
98
+
99
+ value = value.strip()
100
+
101
+ # Try int
102
+ try:
103
+ return int(value)
104
+ except ValueError:
105
+ pass
106
+
107
+ # Try float
108
+ try:
109
+ return float(value)
110
+ except ValueError:
111
+ pass
112
+
113
+ # Keep as string
114
+ return value
115
+
jib/display.py ADDED
@@ -0,0 +1,377 @@
1
+ """Display and repr logic for Vector and Table."""
2
+
3
+ from __future__ import annotations
4
+ from datetime import date
5
+ from typing import List
6
+ from .naming import _get_reserved_names
7
+
8
+
9
+ # How many rows/columns to show before inserting "..."
10
+ MAX_HEAD_ROWS = 5
11
+ MAX_HEAD_COLS = 5
12
+
13
+
14
+ def _needs_quote(name: str) -> bool:
15
+ """Determine if a column name needs quoting in repr output.
16
+
17
+ A name needs quoting if:
18
+ - It's empty
19
+ - It's not a valid Python identifier
20
+ - It starts with a digit
21
+ - It parses as a number
22
+ - It collides with Vector/Table reserved method names
23
+ """
24
+ # Always quote empty names
25
+ if not name:
26
+ return True
27
+
28
+ # If it's not a valid identifier (spaces, punctuation, etc.)
29
+ if not name.isidentifier():
30
+ return True
31
+
32
+ # If it starts with a digit, even if isidentifier() somehow allowed it
33
+ if name[0].isdigit():
34
+ return True
35
+
36
+ # If it parses as a number (int, float, maybe with sign), quote it
37
+ try:
38
+ float(name)
39
+ return True
40
+ except ValueError:
41
+ pass
42
+
43
+ # If the name collides with a method/attribute name, quote it
44
+ if name.lower() in _get_reserved_names():
45
+ return True
46
+
47
+ return False
48
+
49
+
50
+ def _format_column(col, max_preview: int = MAX_HEAD_ROWS) -> List[str]:
51
+ """Returns a list of strings representing that column, truncated for display."""
52
+ # Truncate with symmetric preview
53
+ vals = col._underlying
54
+ if len(vals) > max_preview * 2:
55
+ preview = list(vals[:max_preview]) + ['...'] + list(vals[-max_preview:])
56
+ else:
57
+ preview = list(vals)
58
+
59
+ # Type-sensitive formatting
60
+ out = []
61
+ for v in preview:
62
+ if v == '...':
63
+ out.append('...')
64
+ elif col._dtype and col._dtype.kind is float:
65
+ out.append(f"{v:.1f}" if v == int(v) else f"{v:g}")
66
+ elif col._dtype and col._dtype.kind is int:
67
+ out.append(str(v))
68
+ elif col._dtype and col._dtype.kind is date:
69
+ out.append(v.isoformat())
70
+ elif col._dtype and col._dtype.kind is str:
71
+ # Pure str columns: no quotes (type already known from footer)
72
+ out.append(str(v) if v is not None else 'None')
73
+ else:
74
+ # Object type - quote strings to distinguish from other types
75
+ if isinstance(v, str):
76
+ out.append(repr(v))
77
+ else:
78
+ out.append(str(v))
79
+
80
+ # Align: numeric right, others left
81
+ max_len = max(len(s) for s in out) if out else 0
82
+ if col._dtype and col._dtype.kind in (int, float):
83
+ return [s.rjust(max_len) for s in out]
84
+ return [s.ljust(max_len) for s in out]
85
+
86
+
87
+ def _compute_headers(cols, col_indices, sanitize_func, uniquify_func):
88
+ """Given Table columns and indices, returns display_names, sanitized_names, dtypes."""
89
+ display_names = []
90
+ sanitized_names = []
91
+ dtypes = []
92
+ seen = set()
93
+
94
+ for idx in col_indices:
95
+ col = cols[idx]
96
+
97
+ # Display name
98
+ disp = col._name or ""
99
+ display_names.append(disp)
100
+
101
+ # Sanitized dot name
102
+ if col._name:
103
+ san = sanitize_func(col._name)
104
+ if san is None:
105
+ san = f"col{idx}_"
106
+ else:
107
+ san = uniquify_func(san, seen)
108
+ seen.add(san)
109
+ else:
110
+ san = f"col{idx}_"
111
+ sanitized_names.append(san)
112
+
113
+ # Dtype (with nullable indicator)
114
+ if col._dtype:
115
+ dtype_str = col._dtype.kind.__name__
116
+ if col._dtype.nullable:
117
+ dtype_str += "?"
118
+ dtypes.append(dtype_str)
119
+ else:
120
+ dtypes.append("object")
121
+
122
+ return display_names, sanitized_names, dtypes
123
+
124
+
125
+ def _is_structural_change(display_name: str, sanitized_name: str) -> bool:
126
+ """Check if sanitization involved structural changes beyond just case normalization.
127
+
128
+ Returns True if there were meaningful transformations like:
129
+ - Character removal/substitution (spaces, punctuation)
130
+ - Prefix addition (digit handling)
131
+ - Suffix addition (reserved name collision)
132
+
133
+ Returns False if only case changed.
134
+ """
135
+ if not display_name or not sanitized_name:
136
+ return True
137
+
138
+ # If lowercasing the display name equals sanitized, it's just case change
139
+ if display_name.lower() == sanitized_name:
140
+ return False
141
+
142
+ # Otherwise there was a structural transformation
143
+ return True
144
+
145
+
146
+ def _header_rows(display_names, sanitized_names, dtypes):
147
+ """Decide which header rows to show based on display vs sanitized names.
148
+
149
+ Returns (header_rows, show_types_in_header) where show_types_in_header indicates
150
+ whether types are heterogeneous and should be shown in header instead of footer.
151
+ """
152
+ any_display = any(n for n in display_names if n != "...")
153
+
154
+ # Only show dot-access row if there's a structural change, not just case
155
+ any_structural_change = any(
156
+ _is_structural_change(disp, san)
157
+ for disp, san in zip(display_names, sanitized_names)
158
+ if disp != "..." and san != "..."
159
+ )
160
+
161
+ # Check if types are homogeneous (excluding "...")
162
+ unique_types = set(dt for dt in dtypes if dt != "...")
163
+ show_types_in_header = len(unique_types) > 1
164
+
165
+ rows = []
166
+
167
+ # Row 1: display names (quoted if needed)
168
+ if any_display:
169
+ row = []
170
+ for name in display_names:
171
+ if name == "...":
172
+ row.append("...")
173
+ elif _needs_quote(name):
174
+ row.append(repr(name))
175
+ else:
176
+ row.append(name if name else "")
177
+ rows.append(row)
178
+
179
+ # Row 2: sanitized names (only if structural change or no display names)
180
+ if any_structural_change or not any_display:
181
+ rows.append([("." + san) if san and san != "..." else san for san in sanitized_names])
182
+
183
+ # Row 3 (or 2): type annotations (only if heterogeneous)
184
+ if show_types_in_header:
185
+ rows.append([f"[{dt}]" if dt != "..." else "..." for dt in dtypes])
186
+
187
+ return rows, show_types_in_header
188
+
189
+
190
+ def _align_columns(formatted_cols, header_rows, col_dtypes):
191
+ """Pad columns and headers to consistent widths."""
192
+ num_cols = len(formatted_cols)
193
+ col_widths = []
194
+
195
+ # Compute desired width per column
196
+ for c in range(num_cols):
197
+ body_width = max(len(s) for s in formatted_cols[c]) if formatted_cols[c] else 0
198
+ header_width = max(
199
+ len(header_rows[r][c]) for r in range(len(header_rows))
200
+ ) if header_rows else 0
201
+ col_widths.append(max(body_width, header_width))
202
+
203
+ # Re-pad columns based on dtype
204
+ aligned_cols = []
205
+ for c in range(num_cols):
206
+ col = formatted_cols[c]
207
+ w = col_widths[c]
208
+ dtype = col_dtypes[c]
209
+ if dtype in ('int', 'float'):
210
+ aligned_cols.append([s.rjust(w) for s in col])
211
+ else:
212
+ aligned_cols.append([s.ljust(w) for s in col])
213
+
214
+ # Re-pad headers
215
+ aligned_headers = []
216
+ for row in header_rows:
217
+ aligned_headers.append([h.rjust(col_widths[c]) if col_dtypes[c] in ('int', 'float') else h.ljust(col_widths[c])
218
+ for c, h in enumerate(row)])
219
+
220
+ return aligned_cols, aligned_headers
221
+
222
+
223
+ def _footer(pv, dtype_list=None, truncated=False, shown=MAX_HEAD_COLS) -> str:
224
+ """Generate footer line based on shape and dtypes."""
225
+ shape = pv.shape
226
+ if not shape:
227
+ return "# empty"
228
+
229
+ if len(shape) == 1:
230
+ if pv._dtype:
231
+ dt = pv._dtype.kind.__name__
232
+ if pv._dtype.nullable:
233
+ dt += "?"
234
+ else:
235
+ dt = "object"
236
+ return f"# {len(pv)} element vector <{dt}>"
237
+
238
+ if len(shape) == 2:
239
+ if dtype_list:
240
+ if truncated:
241
+ d = ", ".join(dtype_list[:shown]) + ", ..., " + ", ".join(dtype_list[-shown:])
242
+ else:
243
+ d = ", ".join(dtype_list)
244
+ else:
245
+ d = pv._dtype.kind.__name__ if pv._dtype else "object"
246
+ rows, cols = shape
247
+ return f"# {rows}×{cols} table <{d}>"
248
+
249
+ shape_str = "×".join(str(s) for s in shape)
250
+ if pv._dtype:
251
+ dt = pv._dtype.kind.__name__
252
+ if pv._dtype.nullable:
253
+ dt += "?"
254
+ else:
255
+ dt = "object"
256
+ return f"# {shape_str} tensor <{dt}>"
257
+
258
+
259
+ def _repr_vector(v) -> str:
260
+ """Pretty repr for a 1D Vector."""
261
+ formatted = _format_column(v)
262
+
263
+ # Compute width: max of data and header (if present)
264
+ data_width = max(len(s) for s in formatted) if formatted else 0
265
+ header_width = 0
266
+ if v._name:
267
+ header_text = repr(v._name) if _needs_quote(v._name) else v._name
268
+ header_width = len(header_text)
269
+
270
+ width = max(data_width, header_width)
271
+
272
+ # Re-align data to match combined width
273
+ if v._dtype and v._dtype.kind in (int, float):
274
+ formatted = [s.rjust(width) for s in formatted]
275
+ else:
276
+ formatted = [s.ljust(width) for s in formatted]
277
+
278
+ lines = []
279
+
280
+ # Optional vector name
281
+ if v._name:
282
+ lines.append(header_text.ljust(width) if not v._dtype or v._dtype.kind not in (int, float) else header_text.rjust(width))
283
+
284
+ lines.extend(formatted)
285
+ lines.append("")
286
+ lines.append(_footer(v))
287
+ return "\n".join(lines)
288
+
289
+
290
+ def _repr_table(tbl) -> str:
291
+ """Pretty repr for a 2D Table."""
292
+ from .naming import _sanitize_user_name, _uniquify
293
+
294
+ cols = tbl.cols()
295
+ num_cols = len(cols)
296
+
297
+ if num_cols == 0:
298
+ return "# 0×0 table"
299
+
300
+ truncated = num_cols > MAX_HEAD_COLS * 2
301
+
302
+ if truncated:
303
+ col_indices = list(range(MAX_HEAD_COLS)) + list(range(num_cols - MAX_HEAD_COLS, num_cols))
304
+ else:
305
+ col_indices = list(range(num_cols))
306
+
307
+ # Headers + dtypes
308
+ disp, san, dtypes_displayed = _compute_headers(
309
+ cols, col_indices, _sanitize_user_name, _uniquify
310
+ )
311
+
312
+ # Get all dtypes for footer
313
+ dtypes_all = []
314
+ for col in cols:
315
+ if col._dtype:
316
+ dtype_str = col._dtype.kind.__name__
317
+ if col._dtype.nullable:
318
+ dtype_str += "?"
319
+ dtypes_all.append(dtype_str)
320
+ else:
321
+ dtypes_all.append("object")
322
+
323
+ # Format columns
324
+ formatted_cols = [_format_column(cols[i]) for i in col_indices]
325
+
326
+ # Insert "..." column if truncated
327
+ if truncated:
328
+ ellipsis_col = ["..." for _ in range(len(formatted_cols[0]))]
329
+ formatted_cols.insert(MAX_HEAD_COLS, ellipsis_col)
330
+ disp.insert(MAX_HEAD_COLS, "...")
331
+ san.insert(MAX_HEAD_COLS, "...")
332
+ dtypes_displayed.insert(MAX_HEAD_COLS, "...")
333
+
334
+ # Build header rows
335
+ header_rows, show_types_in_header = _header_rows(disp, san, dtypes_displayed)
336
+
337
+ # Align everything
338
+ aligned_cols, aligned_headers = _align_columns(formatted_cols, header_rows, dtypes_displayed)
339
+
340
+ # Build output
341
+ lines = []
342
+ for hrow in aligned_headers:
343
+ lines.append(" ".join(hrow))
344
+
345
+ # Table body
346
+ nrows = len(aligned_cols[0]) if aligned_cols else 0
347
+ for r in range(nrows):
348
+ row = " ".join(col[r] for col in aligned_cols)
349
+ lines.append(row)
350
+
351
+ lines.append("")
352
+
353
+ # Footer: use <mixed> if types are in header, otherwise show type info
354
+ if show_types_in_header:
355
+ lines.append(_footer(tbl, None, False, MAX_HEAD_COLS).replace(f"<{tbl._dtype.kind.__name__ if tbl._dtype else 'object'}>", "<mixed>"))
356
+ else:
357
+ # Check if all dtypes are the same (homogeneous table)
358
+ unique_dtypes = set(dtypes_all)
359
+ if len(unique_dtypes) == 1:
360
+ # Homogeneous - show single type
361
+ lines.append(_footer(tbl, None, False, MAX_HEAD_COLS).replace(f"<{tbl._dtype.kind.__name__ if tbl._dtype else 'object'}>", f"<{dtypes_all[0]}>"))
362
+ else:
363
+ # Keep showing all types
364
+ lines.append(_footer(tbl, dtypes_all, truncated, MAX_HEAD_COLS))
365
+
366
+ return "\n".join(lines)
367
+
368
+
369
+ def _printr(pv) -> str:
370
+ """Entry point used by Vector.__repr__ and Table.__repr__."""
371
+ nd = len(pv.shape)
372
+ if nd == 1:
373
+ return _repr_vector(pv)
374
+ if nd == 2:
375
+ return _repr_table(pv)
376
+ return _footer(pv) + " (repr not yet implemented)"
377
+
jib/errors.py ADDED
@@ -0,0 +1,23 @@
1
+ class JibError(Exception):
2
+ """Base exception for jib library."""
3
+ pass
4
+
5
+
6
+ class JibKeyError(JibError, KeyError):
7
+ """Raised when a column/key is missing."""
8
+ pass
9
+
10
+
11
+ class JibTypeError(JibError, TypeError):
12
+ """Raised for invalid types in API calls."""
13
+ pass
14
+
15
+
16
+ class JibValueError(JibError, ValueError):
17
+ """Raised for invalid values or mismatched lengths."""
18
+ pass
19
+
20
+
21
+ class JibIndexError(JibError, IndexError):
22
+ """Raised for invalid indexing operations."""
23
+ pass