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 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.0'
32
- __version_tuple__ = version_tuple = (2, 0)
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(table_name):
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
- import the passed CSV into the sqlite database
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(self.table_name):
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(self.table_name):
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, table_name: str) -> bool:
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
- from collections import defaultdict, Counter
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
- column_defs = [f'"{col}" {col_type}' for col, col_type in columns.items()]
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
- Search order:
128
- 1. first + last
129
- 2. middle + last (if three parts)
130
- 3. initial 1st letter + last
131
- 4. initial 2nd letter + last (for two-letter initials)
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.0
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` is needed
45
- for 'clang' to be able to find the header files)\
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
@@ -1,5 +0,0 @@
1
- """
2
- Default encoding for importing CSV files
3
- """
4
-
5
- CSV_ENCODING = "UTF-8"
@@ -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,,