memberjojo 2.0__py3-none-any.whl → 2.2__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.
- memberjojo/_version.py +2 -2
- memberjojo/mojo_common.py +36 -6
- memberjojo/mojo_loader.py +200 -5
- memberjojo/mojo_member.py +8 -5
- {memberjojo-2.0.dist-info → memberjojo-2.2.dist-info}/METADATA +4 -6
- memberjojo-2.2.dist-info/RECORD +10 -0
- memberjojo/config.py +0 -5
- memberjojo-2.0.dist-info/RECORD +0 -11
- {memberjojo-2.0.dist-info → memberjojo-2.2.dist-info}/WHEEL +0 -0
- {memberjojo-2.0.dist-info → memberjojo-2.2.dist-info}/licenses/LICENSE +0 -0
memberjojo/_version.py
CHANGED
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.
|
|
32
|
-
__version_tuple__ = version_tuple = (2,
|
|
31
|
+
__version__ = version = '2.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 2)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
memberjojo/mojo_common.py
CHANGED
|
@@ -50,7 +50,7 @@ class MojoSkel:
|
|
|
50
50
|
print(f"Encrypted database {self.db_path} loaded securely.")
|
|
51
51
|
|
|
52
52
|
# After table exists (or after import), build the dataclass
|
|
53
|
-
if self.table_exists(
|
|
53
|
+
if self.table_exists():
|
|
54
54
|
self.row_class = self._build_dataclass_from_table()
|
|
55
55
|
else:
|
|
56
56
|
self.row_class = None
|
|
@@ -122,20 +122,50 @@ class MojoSkel:
|
|
|
122
122
|
|
|
123
123
|
def import_csv(self, csv_path: Path):
|
|
124
124
|
"""
|
|
125
|
-
|
|
125
|
+
Import the passed CSV into the encrypted sqlite database.
|
|
126
|
+
If a previous table exists, generate a diff using
|
|
127
|
+
mojo_loader.diff_cipher_tables().
|
|
126
128
|
|
|
127
129
|
:param csv_path: Path like path of csv file.
|
|
128
130
|
"""
|
|
131
|
+
old_table = f"{self.table_name}_old"
|
|
132
|
+
had_existing = self.table_exists()
|
|
133
|
+
|
|
134
|
+
# 1. Preserve existing table
|
|
135
|
+
if had_existing:
|
|
136
|
+
self.conn.execute(f"ALTER TABLE {self.table_name} RENAME TO {old_table}")
|
|
137
|
+
|
|
138
|
+
# 2. Import CSV as new table
|
|
129
139
|
mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
|
|
130
140
|
self.row_class = self._build_dataclass_from_table()
|
|
131
141
|
|
|
142
|
+
if not had_existing:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
# 3. Diff old vs new (SQLCipher → sqlite3 → dataclasses)
|
|
147
|
+
diff_rows = mojo_loader.diff_cipher_tables(
|
|
148
|
+
self.conn,
|
|
149
|
+
new_table=self.table_name,
|
|
150
|
+
old_table=old_table,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if diff_rows:
|
|
154
|
+
for diff in diff_rows:
|
|
155
|
+
# diff is a DiffRow dataclass
|
|
156
|
+
print(diff.diff_type, diff.preview)
|
|
157
|
+
|
|
158
|
+
finally:
|
|
159
|
+
# 4. Cleanup old table (always)
|
|
160
|
+
self.conn.execute(f"DROP TABLE {old_table}")
|
|
161
|
+
|
|
132
162
|
def show_table(self, limit: int = 2):
|
|
133
163
|
"""
|
|
134
164
|
Print the first few rows of the table as dictionaries.
|
|
135
165
|
|
|
136
166
|
:param limit: (optional) Number of rows to display. Defaults to 2.
|
|
137
167
|
"""
|
|
138
|
-
if self.table_exists(
|
|
168
|
+
if self.table_exists():
|
|
139
169
|
self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
|
|
140
170
|
rows = self.cursor.fetchall()
|
|
141
171
|
|
|
@@ -150,7 +180,7 @@ class MojoSkel:
|
|
|
150
180
|
"""
|
|
151
181
|
:return: count of the number of rows in the table, or 0 if no table.
|
|
152
182
|
"""
|
|
153
|
-
if self.table_exists(
|
|
183
|
+
if self.table_exists():
|
|
154
184
|
self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
|
|
155
185
|
result = self.cursor.fetchone()
|
|
156
186
|
return result[0] if result else 0
|
|
@@ -216,12 +246,12 @@ class MojoSkel:
|
|
|
216
246
|
self.cursor.execute(base_query, values)
|
|
217
247
|
return self.cursor.fetchall()
|
|
218
248
|
|
|
219
|
-
def table_exists(self
|
|
249
|
+
def table_exists(self) -> bool:
|
|
220
250
|
"""
|
|
221
251
|
Return true or false if a table exists
|
|
222
252
|
"""
|
|
223
253
|
self.cursor.execute(
|
|
224
254
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
|
|
225
|
-
(table_name,),
|
|
255
|
+
(self.table_name,),
|
|
226
256
|
)
|
|
227
257
|
return self.cursor.fetchone() is not None
|
memberjojo/mojo_loader.py
CHANGED
|
@@ -3,10 +3,28 @@
|
|
|
3
3
|
Helper module for importing a CSV into a SQLite database.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from collections import defaultdict, Counter
|
|
6
7
|
from csv import DictReader
|
|
8
|
+
from dataclasses import dataclass
|
|
7
9
|
from pathlib import Path
|
|
10
|
+
from typing import Any, Tuple
|
|
11
|
+
|
|
8
12
|
import re
|
|
9
|
-
|
|
13
|
+
import sqlite3 as sqlite3_builtin
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class DiffRow:
|
|
18
|
+
"""
|
|
19
|
+
Represents a single diff result.
|
|
20
|
+
|
|
21
|
+
- diff_type: 'added' | 'deleted' | 'changed'
|
|
22
|
+
- preview: tuple of values, with preview[0] == key
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
diff_type: str
|
|
26
|
+
preview: Tuple[Any, ...]
|
|
27
|
+
|
|
10
28
|
|
|
11
29
|
# -----------------------
|
|
12
30
|
# Normalization & Type Guessing
|
|
@@ -29,6 +47,10 @@ def _normalize(name: str) -> str:
|
|
|
29
47
|
def _guess_type(value: any) -> str:
|
|
30
48
|
"""
|
|
31
49
|
Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'.
|
|
50
|
+
|
|
51
|
+
:param value: entry from sqlite database to guess the type of
|
|
52
|
+
|
|
53
|
+
:return: string of the type, TEXT, INTEGER, REAL
|
|
32
54
|
"""
|
|
33
55
|
if value is None:
|
|
34
56
|
return "TEXT"
|
|
@@ -51,6 +73,10 @@ def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
|
51
73
|
"""
|
|
52
74
|
Infer column types from CSV rows.
|
|
53
75
|
Returns mapping: normalized column name -> SQLite type.
|
|
76
|
+
|
|
77
|
+
:param rows: list of rows to use for inference
|
|
78
|
+
|
|
79
|
+
:return: dict of name, type for columns
|
|
54
80
|
"""
|
|
55
81
|
type_counters = defaultdict(Counter)
|
|
56
82
|
|
|
@@ -85,11 +111,18 @@ def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
|
85
111
|
|
|
86
112
|
:return: SQL commands to create the table.
|
|
87
113
|
"""
|
|
88
|
-
|
|
114
|
+
col_defs = []
|
|
115
|
+
first = True
|
|
116
|
+
|
|
117
|
+
for col, col_type in columns.items():
|
|
118
|
+
if first:
|
|
119
|
+
col_defs.append(f'"{col}" {col_type} PRIMARY KEY')
|
|
120
|
+
first = False
|
|
121
|
+
else:
|
|
122
|
+
col_defs.append(f'"{col}" {col_type}')
|
|
123
|
+
|
|
89
124
|
return (
|
|
90
|
-
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n'
|
|
91
|
-
+ ",\n".join(column_defs)
|
|
92
|
-
+ "\n)"
|
|
125
|
+
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n' + ",\n".join(col_defs) + "\n)"
|
|
93
126
|
)
|
|
94
127
|
|
|
95
128
|
|
|
@@ -138,3 +171,165 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
|
138
171
|
|
|
139
172
|
cursor.close()
|
|
140
173
|
conn.commit()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# -----------------------
|
|
177
|
+
# diff generation
|
|
178
|
+
# -----------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
|
|
182
|
+
"""
|
|
183
|
+
Convert a sqlite3.Row from generate_sql_diff into DiffRow.
|
|
184
|
+
Row shape:
|
|
185
|
+
(diff_type, col1, col2, col3, ...)
|
|
186
|
+
|
|
187
|
+
:param row: Row from sqlite3 database to create a dataclass entry from
|
|
188
|
+
|
|
189
|
+
:return: A dataclass of the row
|
|
190
|
+
"""
|
|
191
|
+
return DiffRow(
|
|
192
|
+
diff_type=row[0],
|
|
193
|
+
preview=tuple(row[1:]),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def diff_cipher_tables(
|
|
198
|
+
cipher_conn,
|
|
199
|
+
*,
|
|
200
|
+
new_table: str,
|
|
201
|
+
old_table: str,
|
|
202
|
+
) -> list[DiffRow]:
|
|
203
|
+
"""
|
|
204
|
+
Copy old and new tables from SQLCipher into a single
|
|
205
|
+
in-memory sqlite3 database and diff them there.
|
|
206
|
+
|
|
207
|
+
:param cipher_conn: sqlite connection to the encrypted db
|
|
208
|
+
:param new_table: name of the new table for comparison
|
|
209
|
+
:param old_table: name of the old table for comparison
|
|
210
|
+
|
|
211
|
+
:return: a list of DiffRow entries of the changed rows
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
plain = sqlite3_builtin.connect(":memory:")
|
|
215
|
+
plain.row_factory = sqlite3_builtin.Row
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
for table in (old_table, new_table):
|
|
219
|
+
# 1. Clone schema using SQLite itself
|
|
220
|
+
schema_sql = cipher_conn.execute(
|
|
221
|
+
"""
|
|
222
|
+
SELECT sql
|
|
223
|
+
FROM sqlite_master
|
|
224
|
+
WHERE type='table' AND name=?
|
|
225
|
+
""",
|
|
226
|
+
(table,),
|
|
227
|
+
).fetchone()
|
|
228
|
+
|
|
229
|
+
if schema_sql is None:
|
|
230
|
+
raise RuntimeError(f"Table {table!r} not found in cipher DB")
|
|
231
|
+
|
|
232
|
+
plain.execute(schema_sql[0])
|
|
233
|
+
|
|
234
|
+
# 2. Copy data
|
|
235
|
+
rows = cipher_conn.execute(f"SELECT * FROM {table}")
|
|
236
|
+
cols = [d[0] for d in rows.description]
|
|
237
|
+
|
|
238
|
+
col_list = ", ".join(cols)
|
|
239
|
+
placeholders = ", ".join("?" for _ in cols)
|
|
240
|
+
|
|
241
|
+
plain.executemany(
|
|
242
|
+
f"INSERT INTO {table} ({col_list}) VALUES ({placeholders})",
|
|
243
|
+
rows.fetchall(),
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# 3. Run sqlite-only diff
|
|
247
|
+
rows = _generate_sql_diff(
|
|
248
|
+
plain,
|
|
249
|
+
new_table=new_table,
|
|
250
|
+
old_table=old_table,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return [_diffrow_from_sql_row(r) for r in rows]
|
|
254
|
+
|
|
255
|
+
finally:
|
|
256
|
+
plain.close()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _generate_sql_diff(
|
|
260
|
+
conn: sqlite3_builtin.Connection,
|
|
261
|
+
*,
|
|
262
|
+
new_table: str,
|
|
263
|
+
old_table: str,
|
|
264
|
+
) -> list[sqlite3_builtin.Row]:
|
|
265
|
+
"""
|
|
266
|
+
Generate a diff between two tables using standard SQLite features.
|
|
267
|
+
|
|
268
|
+
- The FIRST column is the primary key.
|
|
269
|
+
- Returned row shape:
|
|
270
|
+
(diff_type, preview_col1, preview_col2, preview_col3, ...)
|
|
271
|
+
|
|
272
|
+
:param conn: sqlite connection to the db, using python builtin sqlite
|
|
273
|
+
:param new_table: name of the new table to use for comparison
|
|
274
|
+
:param old_table: name of the old table to use for comparison
|
|
275
|
+
|
|
276
|
+
:return: list of sqlite rows that are changed
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
# 1. Introspect schema (order-preserving)
|
|
280
|
+
cols_info = conn.execute(f"PRAGMA table_info({new_table})").fetchall()
|
|
281
|
+
|
|
282
|
+
if not cols_info:
|
|
283
|
+
raise RuntimeError(f"Table {new_table!r} has no columns")
|
|
284
|
+
|
|
285
|
+
cols = [row[1] for row in cols_info]
|
|
286
|
+
|
|
287
|
+
key = cols[0]
|
|
288
|
+
non_key_cols = cols[1:]
|
|
289
|
+
|
|
290
|
+
# 2. Preview columns (key first, limit for readability)
|
|
291
|
+
preview_cols = [key] + non_key_cols[:5]
|
|
292
|
+
|
|
293
|
+
new_preview = ", ".join(f"n.{c}" for c in preview_cols)
|
|
294
|
+
old_preview = ", ".join(f"o.{c}" for c in preview_cols)
|
|
295
|
+
|
|
296
|
+
# 3. Row-value comparison (NULL-safe)
|
|
297
|
+
if non_key_cols:
|
|
298
|
+
changed_predicate = (
|
|
299
|
+
f"({', '.join(f'n.{c}' for c in non_key_cols)}) "
|
|
300
|
+
f"IS NOT "
|
|
301
|
+
f"({', '.join(f'o.{c}' for c in non_key_cols)})"
|
|
302
|
+
)
|
|
303
|
+
else:
|
|
304
|
+
# Key-only table
|
|
305
|
+
changed_predicate = "0"
|
|
306
|
+
|
|
307
|
+
sql = f"""
|
|
308
|
+
WITH
|
|
309
|
+
added AS (
|
|
310
|
+
SELECT 'added' AS diff_type, {new_preview}
|
|
311
|
+
FROM {new_table} n
|
|
312
|
+
LEFT JOIN {old_table} o USING ({key})
|
|
313
|
+
WHERE o.{key} IS NULL
|
|
314
|
+
),
|
|
315
|
+
deleted AS (
|
|
316
|
+
SELECT 'deleted' AS diff_type, {old_preview}
|
|
317
|
+
FROM {old_table} o
|
|
318
|
+
LEFT JOIN {new_table} n USING ({key})
|
|
319
|
+
WHERE n.{key} IS NULL
|
|
320
|
+
),
|
|
321
|
+
changed AS (
|
|
322
|
+
SELECT 'changed' AS diff_type, {new_preview}
|
|
323
|
+
FROM {new_table} n
|
|
324
|
+
JOIN {old_table} o USING ({key})
|
|
325
|
+
WHERE {changed_predicate}
|
|
326
|
+
)
|
|
327
|
+
SELECT * FROM added
|
|
328
|
+
UNION ALL
|
|
329
|
+
SELECT * FROM deleted
|
|
330
|
+
UNION ALL
|
|
331
|
+
SELECT * FROM changed
|
|
332
|
+
ORDER BY {key};
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
return list(conn.execute(sql))
|
memberjojo/mojo_member.py
CHANGED
|
@@ -124,11 +124,14 @@ class Member(MojoSkel):
|
|
|
124
124
|
) -> Optional[tuple]:
|
|
125
125
|
"""
|
|
126
126
|
Resolve a member name from a free-text full name.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
127
|
+
|
|
128
|
+
**Search order**
|
|
129
|
+
|
|
130
|
+
1. first + last
|
|
131
|
+
2. middle + last (if three parts)
|
|
132
|
+
3. initial 1st letter + last
|
|
133
|
+
4. initial 2nd letter + last (for two-letter initials)
|
|
134
|
+
|
|
132
135
|
Returns (first_name, last_name) or None.
|
|
133
136
|
|
|
134
137
|
:param full_name: Full name of the member to find.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memberjojo
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2
|
|
4
4
|
Summary: memberjojo - tools for working with members.
|
|
5
5
|
Author-email: Duncan Bellamy <dunk@denkimushi.com>
|
|
6
6
|
Requires-Python: >=3.8
|
|
@@ -32,17 +32,15 @@ anything on Membermojo.\
|
|
|
32
32
|
It provides tools to load, and query membership and transaction data efficiently
|
|
33
33
|
without having to use SQLite directly.\
|
|
34
34
|
When importing CSV files existing entries are dropped before import, so you can
|
|
35
|
-
just import the latest download and the local database is updated
|
|
35
|
+
just import the latest download and the local database is updated.
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
39
39
|
## Installation
|
|
40
40
|
|
|
41
|
-
Install via `pip`:
|
|
42
|
-
|
|
43
41
|
Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
44
|
-
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH`
|
|
45
|
-
|
|
42
|
+
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
|
|
43
|
+
`LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
|
|
46
44
|
|
|
47
45
|
```bash
|
|
48
46
|
brew install sqlcipher
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
memberjojo/__init__.py,sha256=JGhIJ1HGbzrwzF4HsopOdP4eweLjUmFjbIDvMbk1MKI,291
|
|
2
|
+
memberjojo/_version.py,sha256=_Nk5-Ctb0Jdj1fjUXI6x5dxb-FwdE56jfaBDPomGv_A,699
|
|
3
|
+
memberjojo/mojo_common.py,sha256=DqJ3NKq1Lrcv8NejJFR3ZHlwZifTjqIZv6Vp0R8yOQQ,8340
|
|
4
|
+
memberjojo/mojo_loader.py,sha256=pKqp7QiirThYd6gIy1LQ48GbV2DVFL0yZSLUWxuTLa0,9232
|
|
5
|
+
memberjojo/mojo_member.py,sha256=3UeBIVtSXBCUpvuGN9fIJ0zgXu6vXhs4wFDHeDnOG0o,8078
|
|
6
|
+
memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
|
|
7
|
+
memberjojo-2.2.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
8
|
+
memberjojo-2.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
9
|
+
memberjojo-2.2.dist-info/METADATA,sha256=p8d0fTLtfNk5Hir3CRwjBl5WhSYrZL4UpiokpsE0Wsk,3593
|
|
10
|
+
memberjojo-2.2.dist-info/RECORD,,
|
memberjojo/config.py
DELETED
memberjojo-2.0.dist-info/RECORD
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
memberjojo/__init__.py,sha256=JGhIJ1HGbzrwzF4HsopOdP4eweLjUmFjbIDvMbk1MKI,291
|
|
2
|
-
memberjojo/_version.py,sha256=BQP3Alj0qj-vHGLR2CI-B53HBhY5UAffEPyInuYZ0jU,699
|
|
3
|
-
memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
|
|
4
|
-
memberjojo/mojo_common.py,sha256=uisTDjc7SCcTIecF35issytUzD8cHn3Tx7YbDZkp64E,7402
|
|
5
|
-
memberjojo/mojo_loader.py,sha256=PZRrdjCCqatrGbqyqPoI5Pg6IWmeVduqyE8zV9GwKlg,3935
|
|
6
|
-
memberjojo/mojo_member.py,sha256=1Tei14y3opJ4To0KerUwJ7_kidFB87jKWZ7KjDN3B4k,8088
|
|
7
|
-
memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
|
|
8
|
-
memberjojo-2.0.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
9
|
-
memberjojo-2.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
10
|
-
memberjojo-2.0.dist-info/METADATA,sha256=rGQLFERi8EpqH5oowAatO5xIOtaNc987BBOH4DY0zc4,3602
|
|
11
|
-
memberjojo-2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|