memberjojo 2.0__tar.gz → 2.2__tar.gz
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-2.0 → memberjojo-2.2}/PKG-INFO +4 -6
- {memberjojo-2.0 → memberjojo-2.2}/README.md +3 -5
- {memberjojo-2.0 → memberjojo-2.2}/src/memberjojo/_version.py +3 -3
- {memberjojo-2.0 → memberjojo-2.2}/src/memberjojo/mojo_common.py +36 -6
- memberjojo-2.2/src/memberjojo/mojo_loader.py +335 -0
- {memberjojo-2.0 → memberjojo-2.2}/src/memberjojo/mojo_member.py +8 -5
- memberjojo-2.2/tests/test_mojo_import_diff.py +86 -0
- memberjojo-2.2/tests/test_mojo_members_iter.py +140 -0
- memberjojo-2.0/src/memberjojo/config.py +0 -5
- memberjojo-2.0/src/memberjojo/mojo_loader.py +0 -140
- memberjojo-2.0/tests/test_mojo_members_iter.py +0 -66
- {memberjojo-2.0 → memberjojo-2.2}/LICENSE +0 -0
- {memberjojo-2.0 → memberjojo-2.2}/pyproject.toml +0 -0
- {memberjojo-2.0 → memberjojo-2.2}/src/memberjojo/__init__.py +0 -0
- {memberjojo-2.0 → memberjojo-2.2}/src/memberjojo/mojo_transaction.py +0 -0
- {memberjojo-2.0 → memberjojo-2.2}/tests/test_mojo_members.py +0 -0
- {memberjojo-2.0 → memberjojo-2.2}/tests/test_mojo_transactions.py +0 -0
- {memberjojo-2.0 → memberjojo-2.2}/tests/test_version.py +0 -0
|
@@ -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
|
|
@@ -8,17 +8,15 @@ anything on Membermojo.\
|
|
|
8
8
|
It provides tools to load, and query membership and transaction data efficiently
|
|
9
9
|
without having to use SQLite directly.\
|
|
10
10
|
When importing CSV files existing entries are dropped before import, so you can
|
|
11
|
-
just import the latest download and the local database is updated
|
|
11
|
+
just import the latest download and the local database is updated.
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
17
|
-
Install via `pip`:
|
|
18
|
-
|
|
19
17
|
Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
20
|
-
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH`
|
|
21
|
-
|
|
18
|
+
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
|
|
19
|
+
`LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
|
|
22
20
|
|
|
23
21
|
```bash
|
|
24
22
|
brew install sqlcipher
|
|
@@ -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
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g055fb9fe9'
|
|
@@ -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
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Helper module for importing a CSV into a SQLite database.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from collections import defaultdict, Counter
|
|
7
|
+
from csv import DictReader
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Tuple
|
|
11
|
+
|
|
12
|
+
import re
|
|
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
|
+
|
|
28
|
+
|
|
29
|
+
# -----------------------
|
|
30
|
+
# Normalization & Type Guessing
|
|
31
|
+
# -----------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize(name: str) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Normalize a column name: lowercase, remove symbols, convert to snake case.
|
|
37
|
+
|
|
38
|
+
:param name: Raw name to normalize.
|
|
39
|
+
|
|
40
|
+
:return: Normalized lowercase string in snake case with no symbols.
|
|
41
|
+
"""
|
|
42
|
+
name = name.strip().lower()
|
|
43
|
+
name = re.sub(r"[^a-z0-9]+", "_", name)
|
|
44
|
+
return name.strip("_")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _guess_type(value: any) -> str:
|
|
48
|
+
"""
|
|
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
|
|
54
|
+
"""
|
|
55
|
+
if value is None:
|
|
56
|
+
return "TEXT"
|
|
57
|
+
if isinstance(value, str):
|
|
58
|
+
value = value.strip()
|
|
59
|
+
if value == "":
|
|
60
|
+
return "TEXT"
|
|
61
|
+
try:
|
|
62
|
+
int(value)
|
|
63
|
+
return "INTEGER"
|
|
64
|
+
except (ValueError, TypeError):
|
|
65
|
+
try:
|
|
66
|
+
float(value)
|
|
67
|
+
return "REAL"
|
|
68
|
+
except (ValueError, TypeError):
|
|
69
|
+
return "TEXT"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
73
|
+
"""
|
|
74
|
+
Infer column types from CSV rows.
|
|
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
|
|
80
|
+
"""
|
|
81
|
+
type_counters = defaultdict(Counter)
|
|
82
|
+
|
|
83
|
+
for row in rows:
|
|
84
|
+
for key, value in row.items():
|
|
85
|
+
norm_key = _normalize(key)
|
|
86
|
+
type_counters[norm_key][_guess_type(value)] += 1
|
|
87
|
+
|
|
88
|
+
inferred_cols = {}
|
|
89
|
+
for col, counter in type_counters.items():
|
|
90
|
+
if counter["TEXT"] == 0:
|
|
91
|
+
if counter["REAL"] > 0:
|
|
92
|
+
inferred_cols[col] = "REAL"
|
|
93
|
+
else:
|
|
94
|
+
inferred_cols[col] = "INTEGER"
|
|
95
|
+
else:
|
|
96
|
+
inferred_cols[col] = "TEXT"
|
|
97
|
+
return inferred_cols
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# -----------------------
|
|
101
|
+
# Table Creation
|
|
102
|
+
# -----------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Generate CREATE TABLE SQL from column type mapping.
|
|
108
|
+
|
|
109
|
+
:param table_name: Table to use when creating columns.
|
|
110
|
+
:param columns: dict of columns to create.
|
|
111
|
+
|
|
112
|
+
:return: SQL commands to create the table.
|
|
113
|
+
"""
|
|
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
|
+
|
|
124
|
+
return (
|
|
125
|
+
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n' + ",\n".join(col_defs) + "\n)"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# -----------------------
|
|
130
|
+
# CSV Import
|
|
131
|
+
# -----------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
135
|
+
"""
|
|
136
|
+
Import CSV into database using given cursor.
|
|
137
|
+
Column types inferred automatically.
|
|
138
|
+
|
|
139
|
+
:param conn: SQLite database connection to use.
|
|
140
|
+
:param table_name: Table to import the CSV into.
|
|
141
|
+
:param csv_path: Path like path of the CSV file to import.
|
|
142
|
+
"""
|
|
143
|
+
if not csv_path.exists():
|
|
144
|
+
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
145
|
+
|
|
146
|
+
# Read CSV rows
|
|
147
|
+
with csv_path.open(newline="", encoding="utf-8") as f:
|
|
148
|
+
reader = list(DictReader(f))
|
|
149
|
+
if not reader:
|
|
150
|
+
raise ValueError("CSV file is empty.")
|
|
151
|
+
inferred_cols = infer_columns_from_rows(reader)
|
|
152
|
+
|
|
153
|
+
cursor = conn.cursor()
|
|
154
|
+
# Drop existing table
|
|
155
|
+
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
156
|
+
|
|
157
|
+
# Create table
|
|
158
|
+
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
159
|
+
cursor.execute(create_sql)
|
|
160
|
+
|
|
161
|
+
# Insert rows
|
|
162
|
+
cols = list(reader[0].keys())
|
|
163
|
+
norm_map = {c: _normalize(c) for c in cols}
|
|
164
|
+
colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
|
|
165
|
+
placeholders = ",".join("?" for _ in cols)
|
|
166
|
+
insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
|
|
167
|
+
|
|
168
|
+
for row in reader:
|
|
169
|
+
values = [row[c] if row[c] != "" else None for c in cols]
|
|
170
|
+
cursor.execute(insert_sql, values)
|
|
171
|
+
|
|
172
|
+
cursor.close()
|
|
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))
|
|
@@ -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.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CSV diff tests for the Member class
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import csv
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from memberjojo.mojo_common import MojoSkel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_csv(path: Path, rows: list[dict]):
|
|
13
|
+
"""Utility: write CSV with DictWriter."""
|
|
14
|
+
with path.open("w", newline="", encoding="utf-8") as f:
|
|
15
|
+
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
|
|
16
|
+
writer.writeheader()
|
|
17
|
+
writer.writerows(rows)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_import_new_csv_generates_diff(tmp_path, capsys):
|
|
21
|
+
"""
|
|
22
|
+
Test importing one CSV, then importing a changed CSV,
|
|
23
|
+
and verify that the diff prints correctly.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# -----------------------
|
|
27
|
+
# 1. Original CSV
|
|
28
|
+
# -----------------------
|
|
29
|
+
csv1 = tmp_path / "members1.csv"
|
|
30
|
+
original_rows = [
|
|
31
|
+
{"id": "1", "name": "Alice", "age": "30"},
|
|
32
|
+
{"id": "2", "name": "Bob", "age": "40"},
|
|
33
|
+
]
|
|
34
|
+
write_csv(csv1, original_rows)
|
|
35
|
+
|
|
36
|
+
# Create DB
|
|
37
|
+
db_path = tmp_path / "test_members.db"
|
|
38
|
+
password = "Needs a Password"
|
|
39
|
+
|
|
40
|
+
# Load initial CSV
|
|
41
|
+
m = MojoSkel(str(db_path), password, "members")
|
|
42
|
+
m.import_csv(csv1)
|
|
43
|
+
|
|
44
|
+
assert m.count() == 2
|
|
45
|
+
|
|
46
|
+
# -----------------------
|
|
47
|
+
# 2. Modified CSV with one added, one deleted, one changed
|
|
48
|
+
# -----------------------
|
|
49
|
+
csv2 = tmp_path / "members2.csv"
|
|
50
|
+
updated_rows = [
|
|
51
|
+
{"id": "1", "name": "Alice", "age": "31"}, # changed: age 30 -> 31
|
|
52
|
+
{"id": "3", "name": "Cara", "age": "22"}, # added
|
|
53
|
+
]
|
|
54
|
+
write_csv(csv2, updated_rows)
|
|
55
|
+
|
|
56
|
+
# Import second CSV → should trigger rename + diff + drop old table
|
|
57
|
+
m.import_csv(csv2)
|
|
58
|
+
|
|
59
|
+
# Capture printed diff output
|
|
60
|
+
captured = capsys.readouterr().out
|
|
61
|
+
|
|
62
|
+
# -----------------------
|
|
63
|
+
# 3. Check diff lines
|
|
64
|
+
# -----------------------
|
|
65
|
+
# We expect:
|
|
66
|
+
# - rowid 1: changed (age differs)
|
|
67
|
+
# - rowid 2: deleted (Bob missing)
|
|
68
|
+
# - rowid 3: added (Cara new)
|
|
69
|
+
#
|
|
70
|
+
# Diff output rows look like:
|
|
71
|
+
# (1, 'changed')
|
|
72
|
+
# (2, 'deleted')
|
|
73
|
+
# (3, 'added')
|
|
74
|
+
|
|
75
|
+
assert "changed" in captured
|
|
76
|
+
assert "deleted" in captured
|
|
77
|
+
assert "added" in captured
|
|
78
|
+
|
|
79
|
+
# Count rows after second import
|
|
80
|
+
assert m.count() == 2
|
|
81
|
+
|
|
82
|
+
# Ensure old table is gone
|
|
83
|
+
cur = m.conn.execute(
|
|
84
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='members_old'"
|
|
85
|
+
)
|
|
86
|
+
assert cur.fetchone() is None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Iteration tests for the Member class
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import pytest
|
|
7
|
+
from memberjojo import Member
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# conftest.py or at top of test file
|
|
11
|
+
SAMPLE_MEMBERS = [
|
|
12
|
+
{
|
|
13
|
+
"Member number": "11",
|
|
14
|
+
"Title": "Mr",
|
|
15
|
+
"First name": "Johnny",
|
|
16
|
+
"Last name": "Doe",
|
|
17
|
+
"membermojo ID": "8001",
|
|
18
|
+
"Short URL": "http://short.url/johnny",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"Member number": "12",
|
|
22
|
+
"Title": "Ms",
|
|
23
|
+
"First name": "Janice",
|
|
24
|
+
"Last name": "Smith",
|
|
25
|
+
"membermojo ID": "8002",
|
|
26
|
+
"Short URL": "http://short.url/janice",
|
|
27
|
+
},
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_member_iter(tmp_path):
|
|
32
|
+
"""
|
|
33
|
+
Test for iterating over the member data.
|
|
34
|
+
"""
|
|
35
|
+
# Prepare sample CSV data
|
|
36
|
+
sample_csv = tmp_path / "members.csv"
|
|
37
|
+
|
|
38
|
+
# Write CSV file
|
|
39
|
+
with sample_csv.open("w", newline="", encoding="utf-8") as f:
|
|
40
|
+
writer = csv.DictWriter(f, fieldnames=SAMPLE_MEMBERS[0].keys())
|
|
41
|
+
writer.writeheader()
|
|
42
|
+
writer.writerows(SAMPLE_MEMBERS)
|
|
43
|
+
|
|
44
|
+
# Create DB path
|
|
45
|
+
db_path = tmp_path / "test_members.db"
|
|
46
|
+
|
|
47
|
+
# Instantiate Member and import CSV
|
|
48
|
+
members = Member(db_path, "Needs a Password")
|
|
49
|
+
members.import_csv(sample_csv)
|
|
50
|
+
|
|
51
|
+
# Collect members from iterator
|
|
52
|
+
iterated_members = list(members)
|
|
53
|
+
|
|
54
|
+
# Check that iteration yields correct number of members
|
|
55
|
+
assert len(iterated_members) == len(SAMPLE_MEMBERS)
|
|
56
|
+
|
|
57
|
+
# Check that fields match for first member
|
|
58
|
+
first = iterated_members[0]
|
|
59
|
+
assert isinstance(first, members.row_class)
|
|
60
|
+
assert first.member_number == int(SAMPLE_MEMBERS[0]["Member number"])
|
|
61
|
+
assert first.title == SAMPLE_MEMBERS[0]["Title"]
|
|
62
|
+
assert first.first_name == SAMPLE_MEMBERS[0]["First name"]
|
|
63
|
+
assert first.last_name == SAMPLE_MEMBERS[0]["Last name"]
|
|
64
|
+
assert first.membermojo_id == int(SAMPLE_MEMBERS[0]["membermojo ID"])
|
|
65
|
+
assert first.short_url == SAMPLE_MEMBERS[0]["Short URL"]
|
|
66
|
+
|
|
67
|
+
# Check second member also matches
|
|
68
|
+
second = iterated_members[1]
|
|
69
|
+
assert second.first_name == SAMPLE_MEMBERS[1]["First name"]
|
|
70
|
+
assert second.last_name == SAMPLE_MEMBERS[1]["Last name"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_iter_without_loading(tmp_path):
|
|
74
|
+
"""
|
|
75
|
+
Iterating a MojoSkel instance without loading a table
|
|
76
|
+
should raise RuntimeError.
|
|
77
|
+
"""
|
|
78
|
+
db_path = tmp_path / "test.db"
|
|
79
|
+
m = Member(str(db_path), "dummy_password", "members")
|
|
80
|
+
|
|
81
|
+
with pytest.raises(RuntimeError, match="Table not loaded yet"):
|
|
82
|
+
# Attempting iteration without loading CSV
|
|
83
|
+
for _ in m:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_member_import_persist_and_reload(tmp_path):
|
|
88
|
+
"""
|
|
89
|
+
Test importing a CSV, closing the DB, reopening it,
|
|
90
|
+
and verifying that the data matches exactly.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
# ------------------------
|
|
94
|
+
# 1. Prepare CSV test data
|
|
95
|
+
# ------------------------
|
|
96
|
+
sample_csv = tmp_path / "members.csv"
|
|
97
|
+
|
|
98
|
+
with sample_csv.open("w", newline="", encoding="utf-8") as f:
|
|
99
|
+
writer = csv.DictWriter(f, fieldnames=SAMPLE_MEMBERS[0].keys())
|
|
100
|
+
writer.writeheader()
|
|
101
|
+
writer.writerows(SAMPLE_MEMBERS)
|
|
102
|
+
|
|
103
|
+
# ----------------------
|
|
104
|
+
# 2. Import into database
|
|
105
|
+
# ----------------------
|
|
106
|
+
db_path = tmp_path / "test_members.db"
|
|
107
|
+
password = "Needs a Password"
|
|
108
|
+
|
|
109
|
+
members = Member(db_path, password)
|
|
110
|
+
members.import_csv(sample_csv)
|
|
111
|
+
|
|
112
|
+
# Capture original rows
|
|
113
|
+
rows_before = list(members)
|
|
114
|
+
assert len(rows_before) == len(SAMPLE_MEMBERS)
|
|
115
|
+
|
|
116
|
+
# Close DB
|
|
117
|
+
members.conn.close()
|
|
118
|
+
|
|
119
|
+
# ----------------------
|
|
120
|
+
# 3. Re-open the database
|
|
121
|
+
# ----------------------
|
|
122
|
+
members2 = Member(db_path, password)
|
|
123
|
+
|
|
124
|
+
rows_after = list(members2)
|
|
125
|
+
assert len(rows_after) == len(SAMPLE_MEMBERS)
|
|
126
|
+
|
|
127
|
+
# ---------------------------
|
|
128
|
+
# 4. Compare row by row fields
|
|
129
|
+
# ---------------------------
|
|
130
|
+
for before, after in zip(rows_before, rows_after):
|
|
131
|
+
# Same dataclass class?
|
|
132
|
+
assert (
|
|
133
|
+
before.__class__.__name__ == after.__class__.__name__
|
|
134
|
+
), f"Dataclass names differ: {before.__class__} vs {after.__class__}"
|
|
135
|
+
|
|
136
|
+
# Compare all fields generically
|
|
137
|
+
for field in before.__dataclass_fields__:
|
|
138
|
+
assert getattr(before, field) == getattr(
|
|
139
|
+
after, field
|
|
140
|
+
), f"Mismatch in field {field}"
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Helper module for importing a CSV into a SQLite database.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from csv import DictReader
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
import re
|
|
9
|
-
from collections import defaultdict, Counter
|
|
10
|
-
|
|
11
|
-
# -----------------------
|
|
12
|
-
# Normalization & Type Guessing
|
|
13
|
-
# -----------------------
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _normalize(name: str) -> str:
|
|
17
|
-
"""
|
|
18
|
-
Normalize a column name: lowercase, remove symbols, convert to snake case.
|
|
19
|
-
|
|
20
|
-
:param name: Raw name to normalize.
|
|
21
|
-
|
|
22
|
-
:return: Normalized lowercase string in snake case with no symbols.
|
|
23
|
-
"""
|
|
24
|
-
name = name.strip().lower()
|
|
25
|
-
name = re.sub(r"[^a-z0-9]+", "_", name)
|
|
26
|
-
return name.strip("_")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _guess_type(value: any) -> str:
|
|
30
|
-
"""
|
|
31
|
-
Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'.
|
|
32
|
-
"""
|
|
33
|
-
if value is None:
|
|
34
|
-
return "TEXT"
|
|
35
|
-
if isinstance(value, str):
|
|
36
|
-
value = value.strip()
|
|
37
|
-
if value == "":
|
|
38
|
-
return "TEXT"
|
|
39
|
-
try:
|
|
40
|
-
int(value)
|
|
41
|
-
return "INTEGER"
|
|
42
|
-
except (ValueError, TypeError):
|
|
43
|
-
try:
|
|
44
|
-
float(value)
|
|
45
|
-
return "REAL"
|
|
46
|
-
except (ValueError, TypeError):
|
|
47
|
-
return "TEXT"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
51
|
-
"""
|
|
52
|
-
Infer column types from CSV rows.
|
|
53
|
-
Returns mapping: normalized column name -> SQLite type.
|
|
54
|
-
"""
|
|
55
|
-
type_counters = defaultdict(Counter)
|
|
56
|
-
|
|
57
|
-
for row in rows:
|
|
58
|
-
for key, value in row.items():
|
|
59
|
-
norm_key = _normalize(key)
|
|
60
|
-
type_counters[norm_key][_guess_type(value)] += 1
|
|
61
|
-
|
|
62
|
-
inferred_cols = {}
|
|
63
|
-
for col, counter in type_counters.items():
|
|
64
|
-
if counter["TEXT"] == 0:
|
|
65
|
-
if counter["REAL"] > 0:
|
|
66
|
-
inferred_cols[col] = "REAL"
|
|
67
|
-
else:
|
|
68
|
-
inferred_cols[col] = "INTEGER"
|
|
69
|
-
else:
|
|
70
|
-
inferred_cols[col] = "TEXT"
|
|
71
|
-
return inferred_cols
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
# -----------------------
|
|
75
|
-
# Table Creation
|
|
76
|
-
# -----------------------
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
80
|
-
"""
|
|
81
|
-
Generate CREATE TABLE SQL from column type mapping.
|
|
82
|
-
|
|
83
|
-
:param table_name: Table to use when creating columns.
|
|
84
|
-
:param columns: dict of columns to create.
|
|
85
|
-
|
|
86
|
-
:return: SQL commands to create the table.
|
|
87
|
-
"""
|
|
88
|
-
column_defs = [f'"{col}" {col_type}' for col, col_type in columns.items()]
|
|
89
|
-
return (
|
|
90
|
-
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n'
|
|
91
|
-
+ ",\n".join(column_defs)
|
|
92
|
-
+ "\n)"
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# -----------------------
|
|
97
|
-
# CSV Import
|
|
98
|
-
# -----------------------
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
102
|
-
"""
|
|
103
|
-
Import CSV into database using given cursor.
|
|
104
|
-
Column types inferred automatically.
|
|
105
|
-
|
|
106
|
-
:param conn: SQLite database connection to use.
|
|
107
|
-
:param table_name: Table to import the CSV into.
|
|
108
|
-
:param csv_path: Path like path of the CSV file to import.
|
|
109
|
-
"""
|
|
110
|
-
if not csv_path.exists():
|
|
111
|
-
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
112
|
-
|
|
113
|
-
# Read CSV rows
|
|
114
|
-
with csv_path.open(newline="", encoding="utf-8") as f:
|
|
115
|
-
reader = list(DictReader(f))
|
|
116
|
-
if not reader:
|
|
117
|
-
raise ValueError("CSV file is empty.")
|
|
118
|
-
inferred_cols = infer_columns_from_rows(reader)
|
|
119
|
-
|
|
120
|
-
cursor = conn.cursor()
|
|
121
|
-
# Drop existing table
|
|
122
|
-
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
123
|
-
|
|
124
|
-
# Create table
|
|
125
|
-
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
126
|
-
cursor.execute(create_sql)
|
|
127
|
-
|
|
128
|
-
# Insert rows
|
|
129
|
-
cols = list(reader[0].keys())
|
|
130
|
-
norm_map = {c: _normalize(c) for c in cols}
|
|
131
|
-
colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
|
|
132
|
-
placeholders = ",".join("?" for _ in cols)
|
|
133
|
-
insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
|
|
134
|
-
|
|
135
|
-
for row in reader:
|
|
136
|
-
values = [row[c] if row[c] != "" else None for c in cols]
|
|
137
|
-
cursor.execute(insert_sql, values)
|
|
138
|
-
|
|
139
|
-
cursor.close()
|
|
140
|
-
conn.commit()
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Iteration tests for the Member class
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import csv
|
|
6
|
-
from memberjojo import Member
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_member_iter(tmp_path):
|
|
10
|
-
"""
|
|
11
|
-
Test for iterating over the member data.
|
|
12
|
-
"""
|
|
13
|
-
# Prepare sample CSV data
|
|
14
|
-
sample_csv = tmp_path / "members.csv"
|
|
15
|
-
sample_data = [
|
|
16
|
-
{
|
|
17
|
-
"Member number": "11",
|
|
18
|
-
"Title": "Mr",
|
|
19
|
-
"First name": "Johnny",
|
|
20
|
-
"Last name": "Doe",
|
|
21
|
-
"membermojo ID": "8001",
|
|
22
|
-
"Short URL": "http://short.url/johnny",
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"Member number": "12",
|
|
26
|
-
"Title": "Ms",
|
|
27
|
-
"First name": "Janice",
|
|
28
|
-
"Last name": "Smith",
|
|
29
|
-
"membermojo ID": "8002",
|
|
30
|
-
"Short URL": "http://short.url/janice",
|
|
31
|
-
},
|
|
32
|
-
]
|
|
33
|
-
|
|
34
|
-
# Write CSV file
|
|
35
|
-
with sample_csv.open("w", newline="", encoding="utf-8") as f:
|
|
36
|
-
writer = csv.DictWriter(f, fieldnames=sample_data[0].keys())
|
|
37
|
-
writer.writeheader()
|
|
38
|
-
writer.writerows(sample_data)
|
|
39
|
-
|
|
40
|
-
# Create DB path
|
|
41
|
-
db_path = tmp_path / "test_members.db"
|
|
42
|
-
|
|
43
|
-
# Instantiate Member and import CSV
|
|
44
|
-
members = Member(db_path, "Needs a Password")
|
|
45
|
-
members.import_csv(sample_csv)
|
|
46
|
-
|
|
47
|
-
# Collect members from iterator
|
|
48
|
-
iterated_members = list(members)
|
|
49
|
-
|
|
50
|
-
# Check that iteration yields correct number of members
|
|
51
|
-
assert len(iterated_members) == len(sample_data)
|
|
52
|
-
|
|
53
|
-
# Check that fields match for first member
|
|
54
|
-
first = iterated_members[0]
|
|
55
|
-
assert isinstance(first, members.row_class)
|
|
56
|
-
assert first.member_number == int(sample_data[0]["Member number"])
|
|
57
|
-
assert first.title == sample_data[0]["Title"]
|
|
58
|
-
assert first.first_name == sample_data[0]["First name"]
|
|
59
|
-
assert first.last_name == sample_data[0]["Last name"]
|
|
60
|
-
assert first.membermojo_id == int(sample_data[0]["membermojo ID"])
|
|
61
|
-
assert first.short_url == sample_data[0]["Short URL"]
|
|
62
|
-
|
|
63
|
-
# Check second member also matches
|
|
64
|
-
second = iterated_members[1]
|
|
65
|
-
assert second.first_name == sample_data[1]["First name"]
|
|
66
|
-
assert second.last_name == sample_data[1]["Last name"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|