memberjojo 2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memberjojo
3
- Version: 2.1
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
@@ -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` is needed
21
- for 'clang' to be able to find the header files)\
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.1'
32
- __version_tuple__ = version_tuple = (2, 1)
31
+ __version__ = version = '2.2'
32
+ __version_tuple__ = version_tuple = (2, 2)
33
33
 
34
- __commit_id__ = commit_id = 'gcf0cc58f7'
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(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
@@ -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))
@@ -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,5 +0,0 @@
1
- """
2
- Default encoding for importing CSV files
3
- """
4
-
5
- CSV_ENCODING = "UTF-8"
@@ -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