memberjojo 1.2__py3-none-any.whl → 2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
memberjojo/__init__.py CHANGED
@@ -8,5 +8,5 @@ except ModuleNotFoundError:
8
8
  # _version.py is written when building dist
9
9
  __version__ = "0.0.0+local"
10
10
 
11
- from .mojo_member import Member, MemberData
11
+ from .mojo_member import Member
12
12
  from .mojo_transaction import Transaction
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 = '1.2'
32
- __version_tuple__ = version_tuple = (1, 2)
31
+ __version__ = version = '2.1'
32
+ __version_tuple__ = version_tuple = (2, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
memberjojo/mojo_common.py CHANGED
@@ -5,7 +5,16 @@ This module provides a common base class (`MojoSkel`) for other `memberjojo` mod
5
5
  It includes helper methods for working with SQLite databases.
6
6
  """
7
7
 
8
- import sqlite3
8
+ # pylint: disable=no-member
9
+
10
+ from dataclasses import make_dataclass
11
+ from decimal import Decimal, InvalidOperation
12
+ from pathlib import Path
13
+ from typing import Union, List
14
+
15
+ from sqlcipher3 import dbapi2 as sqlite3
16
+
17
+ from . import mojo_loader
9
18
 
10
19
 
11
20
  class MojoSkel:
@@ -14,7 +23,7 @@ class MojoSkel:
14
23
  for querying tables.
15
24
  """
16
25
 
17
- def __init__(self, db_path: str, table_name: str):
26
+ def __init__(self, db_path: str, db_key: str, table_name: str):
18
27
  """
19
28
  Initialize the MojoSkel class.
20
29
 
@@ -22,13 +31,103 @@ class MojoSkel:
22
31
  dictionary-style access to columns.
23
32
 
24
33
  :param db_path: Path to the SQLite database file.
34
+ :param db_key: key to unlock the encrypted sqlite database, or encrypt new one.
25
35
  :param table_name: Name of the table to operate on, or create when importing.
26
36
  """
27
- self.conn = sqlite3.connect(db_path)
37
+ self.db_path = db_path
38
+ self.table_name = table_name
39
+ self.db_key = db_key
28
40
 
41
+ # Open connection
42
+ self.conn = sqlite3.connect(self.db_path)
29
43
  self.conn.row_factory = sqlite3.Row
30
44
  self.cursor = self.conn.cursor()
31
- self.table_name = table_name
45
+
46
+ # Apply SQLCipher key
47
+ self.cursor.execute(f"PRAGMA key='{db_key}'")
48
+ self.cursor.execute("PRAGMA cipher_compatibility = 4")
49
+ print("Cipher:", self.cursor.execute("PRAGMA cipher_version;").fetchone()[0])
50
+ print(f"Encrypted database {self.db_path} loaded securely.")
51
+
52
+ # After table exists (or after import), build the dataclass
53
+ if self.table_exists(table_name):
54
+ self.row_class = self._build_dataclass_from_table()
55
+ else:
56
+ self.row_class = None
57
+
58
+ def __iter__(self):
59
+ """
60
+ Allow iterating over the class, by outputing all members.
61
+ """
62
+ if not self.row_class:
63
+ raise RuntimeError("Table not loaded yet — no dataclass available")
64
+ return self._iter_rows()
65
+
66
+ def _iter_rows(self):
67
+ """
68
+ Iterate over table rows and yield dynamically-created dataclass objects.
69
+ Converts REAL columns to Decimal automatically.
70
+ """
71
+
72
+ sql = f'SELECT * FROM "{self.table_name}"'
73
+
74
+ cur = self.conn.cursor()
75
+ cur.execute(sql)
76
+
77
+ for row in cur.fetchall():
78
+ row_dict = dict(row)
79
+
80
+ # Convert REAL → Decimal
81
+ for k, v in row_dict.items():
82
+ if isinstance(v, float):
83
+ row_dict[k] = Decimal(str(v))
84
+ elif isinstance(v, str):
85
+ # Try converting numeric strings
86
+ try:
87
+ row_dict[k] = Decimal(v)
88
+ except InvalidOperation:
89
+ pass
90
+
91
+ yield self.row_class(**row_dict)
92
+
93
+ def _build_dataclass_from_table(self):
94
+ """
95
+ Dynamically create a dataclass from the table schema.
96
+ INTEGER → int
97
+ REAL → Decimal
98
+ TEXT → str
99
+
100
+ :return: A dataclass built from the table columns and types.
101
+ """
102
+ self.cursor.execute(f'PRAGMA table_info("{self.table_name}")')
103
+ cols = self.cursor.fetchall()
104
+
105
+ if not cols:
106
+ raise ValueError(f"Table '{self.table_name}' does not exist")
107
+
108
+ fields = []
109
+ for _cid, name, col_type, _notnull, _dflt, _pk in cols:
110
+ t = col_type.upper()
111
+
112
+ if t.startswith("INT"):
113
+ py_type = int
114
+ elif t.startswith("REAL") or t.startswith("NUM") or t.startswith("DEC"):
115
+ py_type = Decimal
116
+ else:
117
+ py_type = str
118
+
119
+ fields.append((name, py_type))
120
+
121
+ return make_dataclass(f"{self.table_name}_Row", fields)
122
+
123
+ def import_csv(self, csv_path: Path):
124
+ """
125
+ import the passed CSV into the sqlite database
126
+
127
+ :param csv_path: Path like path of csv file.
128
+ """
129
+ mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
130
+ self.row_class = self._build_dataclass_from_table()
32
131
 
33
132
  def show_table(self, limit: int = 2):
34
133
  """
@@ -36,10 +135,11 @@ class MojoSkel:
36
135
 
37
136
  :param limit: (optional) Number of rows to display. Defaults to 2.
38
137
  """
39
- self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
40
- rows = self.cursor.fetchall()
138
+ if self.table_exists(self.table_name):
139
+ self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
140
+ rows = self.cursor.fetchall()
41
141
 
42
- if not rows:
142
+ else:
43
143
  print("(No data)")
44
144
  return
45
145
 
@@ -48,8 +148,80 @@ class MojoSkel:
48
148
 
49
149
  def count(self) -> int:
50
150
  """
51
- Returns count of the number of rows in the table.
151
+ :return: count of the number of rows in the table, or 0 if no table.
152
+ """
153
+ if self.table_exists(self.table_name):
154
+ self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
155
+ result = self.cursor.fetchone()
156
+ return result[0] if result else 0
157
+
158
+ return 0
159
+
160
+ def get_row(self, entry_name: str, entry_value: str) -> dict:
161
+ """
162
+ Retrieve a single row matching column = value (case-insensitive).
163
+
164
+ :param entry_name: Column name to filter by.
165
+ :param entry_value: Value to match.
166
+
167
+ :return: The matching row as a dictionary, or None if not found.
168
+ """
169
+ if not entry_value:
170
+ return None
171
+ query = (
172
+ f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
173
+ )
174
+ self.cursor.execute(query, (entry_value,))
175
+ row = self.cursor.fetchone()
176
+ return dict(row) if row else None
177
+
178
+ def get_row_multi(
179
+ self, match_dict: dict, only_one: bool = True
180
+ ) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
181
+ """
182
+ Retrieve one or many rows matching multiple column=value pairs.
183
+
184
+ :param match_dict: Dictionary of column names and values to match.
185
+ :param only_one: If True (default), return the first matching row.
186
+ If False, return a list of all matching rows.
187
+
188
+ :return:
189
+ - If only_one=True → a single sqlite3.Row or None
190
+ - If only_one=False → list of sqlite3.Row (may be empty)
191
+ """
192
+ conditions = []
193
+ values = []
194
+
195
+ for col, val in match_dict.items():
196
+ if val is None or val == "":
197
+ conditions.append(f'"{col}" IS NULL')
198
+ else:
199
+ conditions.append(f'"{col}" = ?')
200
+ values.append(
201
+ float(val.quantize(Decimal("0.01")))
202
+ if isinstance(val, Decimal)
203
+ else val
204
+ )
205
+
206
+ base_query = (
207
+ f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
208
+ )
209
+
210
+ if only_one:
211
+ query = base_query + " LIMIT 1"
212
+ self.cursor.execute(query, values)
213
+ return self.cursor.fetchone()
214
+
215
+ # Return *all* rows
216
+ self.cursor.execute(base_query, values)
217
+ return self.cursor.fetchall()
218
+
219
+ def table_exists(self, table_name: str) -> bool:
220
+ """
221
+ Return true or false if a table exists
52
222
  """
53
- self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
54
- result = self.cursor.fetchone()
55
- return result[0] if result else 0
223
+ self.cursor.execute(
224
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
225
+ (table_name,),
226
+ )
227
+ return self.cursor.fetchone() is not None
@@ -0,0 +1,140 @@
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()
memberjojo/mojo_member.py CHANGED
@@ -5,37 +5,11 @@ This module loads data from a `members.csv` file downloaded from Membermojo,
5
5
  stores it in SQLite, and provides helper functions for member lookups.
6
6
  """
7
7
 
8
- from csv import DictReader
9
- from dataclasses import dataclass
10
8
  from pathlib import Path
11
9
  from typing import Optional
12
- import sqlite3
13
- from .config import CSV_ENCODING # import encoding from config.py
14
10
  from .mojo_common import MojoSkel
15
11
 
16
12
 
17
- @dataclass
18
- class MemberData:
19
- """
20
- A dataclass to represent a single member's data for database operations.
21
-
22
- Attributes:
23
- member_num (int): Unique member number (primary key).
24
- title (str): Title (e.g., Mr, Mrs, Ms).
25
- first_name (str): Member's first name.
26
- last_name (str): Member's last name.
27
- membermojo_id (int): Unique Membermojo ID.
28
- short_url (str): Short URL to Membermojo profile.
29
- """
30
-
31
- member_num: int
32
- title: str
33
- first_name: str
34
- last_name: str
35
- membermojo_id: int
36
- short_url: str
37
-
38
-
39
13
  class Member(MojoSkel):
40
14
  """
41
15
  Subclass of MojoSkel providing member-specific database functions.
@@ -45,53 +19,19 @@ class Member(MojoSkel):
45
19
 
46
20
  :param member_db_path (Path): Path to the SQLite database file.
47
21
  :param table_name (str): (optional) Table name to use. Defaults to "members".
22
+ :param db_key: (optional) key to unlock the encrypted sqlite database, unencrypted if unset.
48
23
  """
49
24
 
50
- def __init__(self, member_db_path: Path, table_name: str = "members"):
25
+ def __init__(
26
+ self,
27
+ member_db_path: Path,
28
+ db_key: str,
29
+ table_name: str = "members",
30
+ ):
51
31
  """
52
32
  Initialize the Member database handler.
53
33
  """
54
- super().__init__(member_db_path, table_name)
55
-
56
- def __iter__(self):
57
- """
58
- Allow iterating over the class, by outputing all members.
59
- """
60
- sql = (
61
- f"SELECT member_number, "
62
- f"title, "
63
- f"first_name, "
64
- f"last_name, "
65
- f"membermojo_id, "
66
- f"short_url "
67
- f'FROM "{self.table_name}"'
68
- )
69
- self.cursor.execute(sql)
70
- rows = self.cursor.fetchall()
71
- for row in rows:
72
- yield MemberData(*row)
73
-
74
- def _create_tables(self):
75
- """
76
- Create the members table in the database if it doesn't exist.
77
-
78
- The table includes member number, title, first/last names,
79
- Membermojo ID, and a short profile URL.
80
- """
81
- sql_statements = [
82
- f"""CREATE TABLE IF NOT EXISTS "{self.table_name}" (
83
- member_number INTEGER PRIMARY KEY,
84
- title TEXT NOT NULL CHECK(title IN ('Dr', 'Mr', 'Mrs', 'Miss', 'Ms')),
85
- first_name TEXT NOT NULL,
86
- last_name TEXT NOT NULL,
87
- membermojo_id INTEGER UNIQUE NOT NULL,
88
- short_url TEXT NOT NULL
89
- );"""
90
- ]
91
-
92
- for statement in sql_statements:
93
- self.cursor.execute(statement)
94
- self.conn.commit()
34
+ super().__init__(member_db_path, db_key, table_name)
95
35
 
96
36
  def get_number_first_last(
97
37
  self, first_name: str, last_name: str, found_error: bool = False
@@ -108,9 +48,9 @@ class Member(MojoSkel):
108
48
  :raises ValueError: If not found and `found_error` is True.
109
49
  """
110
50
  sql = f"""
111
- SELECT member_number
51
+ SELECT "member_number"
112
52
  FROM "{self.table_name}"
113
- WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?)
53
+ WHERE LOWER("first_name") = LOWER(?) AND LOWER("last_name") = LOWER(?)
114
54
  """
115
55
  self.cursor.execute(sql, (first_name, last_name))
116
56
  result = self.cursor.fetchone()
@@ -150,10 +90,10 @@ class Member(MojoSkel):
150
90
  :return: Name on membermojo or None
151
91
  """
152
92
  sql = f"""
153
- SELECT first_name, last_name
93
+ SELECT "first_name", "last_name"
154
94
  FROM "{self.table_name}"
155
- WHERE LOWER(first_name) = LOWER(?)
156
- AND LOWER(last_name) = LOWER(?)
95
+ WHERE LOWER("first_name") = LOWER(?)
96
+ AND LOWER("last_name") = LOWER(?)
157
97
  """
158
98
  self.cursor.execute(sql, (first_name, last_name))
159
99
  row = self.cursor.fetchone()
@@ -169,10 +109,10 @@ class Member(MojoSkel):
169
109
  :return: Name on membermojo or None
170
110
  """
171
111
  sql = f"""
172
- SELECT first_name, last_name
112
+ SELECT "first_name", "last_name"
173
113
  FROM "{self.table_name}"
174
- WHERE LOWER(first_name) LIKE LOWER(?) || '%'
175
- AND LOWER(last_name) = LOWER(?)
114
+ WHERE LOWER("first_name") LIKE LOWER(?) || '%'
115
+ AND LOWER("last_name") = LOWER(?)
176
116
  LIMIT 1
177
117
  """
178
118
  self.cursor.execute(sql, (letter, last_name))
@@ -184,11 +124,14 @@ class Member(MojoSkel):
184
124
  ) -> Optional[tuple]:
185
125
  """
186
126
  Resolve a member name from a free-text full name.
187
- Search order:
188
- 1. first + last
189
- 2. middle + last (if three parts)
190
- 3. initial 1st letter + last
191
- 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
+
192
135
  Returns (first_name, last_name) or None.
193
136
 
194
137
  :param full_name: Full name of the member to find.
@@ -271,9 +214,9 @@ class Member(MojoSkel):
271
214
  :return: Full name as tuple, or None if not found.
272
215
  """
273
216
  sql = f"""
274
- SELECT first_name, last_name
217
+ SELECT "first_name", "last_name"
275
218
  FROM "{self.table_name}"
276
- WHERE member_number = ?
219
+ WHERE "member_number" = ?
277
220
  """
278
221
  self.cursor.execute(sql, (member_number,))
279
222
  result = self.cursor.fetchone()
@@ -295,61 +238,3 @@ class Member(MojoSkel):
295
238
  first_name, last_name = result
296
239
  return f"{first_name} {last_name}"
297
240
  return None
298
-
299
- def _add(self, member: MemberData):
300
- """
301
- Insert a member into the database if not already present.
302
-
303
- :param member: The member to add.
304
- """
305
- sql = f"""INSERT OR ABORT INTO "{self.table_name}"
306
- (member_number, title, first_name, last_name, membermojo_id, short_url)
307
- VALUES (?, ?, ?, ?, ?, ?)"""
308
-
309
- try:
310
- self.cursor.execute(
311
- sql,
312
- (
313
- member.member_num,
314
- member.title,
315
- member.first_name,
316
- member.last_name,
317
- member.membermojo_id,
318
- member.short_url,
319
- ),
320
- )
321
- self.conn.commit()
322
- print(
323
- f"Created user {member.member_num}: {member.first_name} {member.last_name}"
324
- )
325
- except sqlite3.IntegrityError:
326
- pass
327
-
328
- def import_csv(self, csv_path: Path):
329
- """
330
- Load members from a Membermojo CSV file and insert into the database.
331
-
332
- :param csv_path: Path to the CSV file.
333
-
334
- Notes:
335
- Only adds members not already in the database (INSERT OR ABORT).
336
- """
337
- print(f"Using SQLite database version {sqlite3.sqlite_version}")
338
- self._create_tables()
339
-
340
- try:
341
- with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
342
- mojo_reader = DictReader(csvfile)
343
-
344
- for row in mojo_reader:
345
- member = MemberData(
346
- member_num=int(row["Member number"]),
347
- title=row["Title"].strip(),
348
- first_name=row["First name"].strip(),
349
- last_name=row["Last name"].strip(),
350
- membermojo_id=int(row["membermojo ID"]),
351
- short_url=row["Short URL"].strip(),
352
- )
353
- self._add(member)
354
- except FileNotFoundError:
355
- print(f"CSV file not found: {csv_path}")
@@ -5,13 +5,6 @@ Provides automatic column type inference, robust CSV importing, and
5
5
  helper methods for querying the database.
6
6
  """
7
7
 
8
- from collections import Counter, defaultdict
9
- from csv import DictReader
10
- from pathlib import Path
11
- from sqlite3 import IntegrityError, OperationalError, DatabaseError
12
- from decimal import Decimal
13
-
14
- from .config import CSV_ENCODING # import encoding from config.py
15
8
  from .mojo_common import MojoSkel
16
9
 
17
10
 
@@ -24,219 +17,16 @@ class Transaction(MojoSkel):
24
17
 
25
18
  :param payment_db_path: Path to the SQLite database.
26
19
  :param table_name: (optional) Name of the table. Defaults to "payments".
20
+ :param db_key: (optional) key to unlock the encrypted sqlite database, unencrypted if unset.
27
21
  """
28
22
 
29
- def __init__(self, payment_db_path: str, table_name: str = "payments"):
23
+ def __init__(
24
+ self,
25
+ payment_db_path: str,
26
+ db_key: str,
27
+ table_name: str = "payments",
28
+ ):
30
29
  """
31
30
  Initialize the Transaction object.
32
31
  """
33
- super().__init__(payment_db_path, table_name)
34
- self.columns = {}
35
-
36
- def _guess_type(self, value: any) -> str:
37
- """
38
- Guess the SQLite data type of a CSV field value.
39
-
40
- :param value: The value from a CSV field.
41
-
42
- :return: One of 'INTEGER', 'REAL', or 'TEXT'.
43
- """
44
- if value is None:
45
- return "TEXT"
46
-
47
- if isinstance(value, str):
48
- value = value.strip()
49
- if value == "":
50
- return "TEXT"
51
-
52
- try:
53
- int(value)
54
- return "INTEGER"
55
- except (ValueError, TypeError):
56
- try:
57
- float(value)
58
- return "REAL"
59
- except (ValueError, TypeError):
60
- return "TEXT"
61
-
62
- def _infer_columns_from_rows(self, rows: list[dict]):
63
- """
64
- Infer SQLite column types based on sample CSV data.
65
-
66
- :param rows: Sample rows from CSV to analyze.
67
- """
68
- type_counters = defaultdict(Counter)
69
-
70
- for row in rows:
71
- for key, value in row.items():
72
- guessed_type = self._guess_type(value)
73
- type_counters[key][guessed_type] += 1
74
-
75
- self.columns = {}
76
- for col, counter in type_counters.items():
77
- if counter["TEXT"] == 0:
78
- if counter["REAL"] > 0:
79
- self.columns[col] = "REAL"
80
- else:
81
- self.columns[col] = "INTEGER"
82
- else:
83
- self.columns[col] = "TEXT"
84
-
85
- print("Inferred columns:", self.columns)
86
-
87
- def _create_tables(self, table: str, primary_col: str):
88
- """
89
- Create the table if it doesn't exist, using inferred schema.
90
-
91
- :param table: Table name.
92
- :param primary_col: Column to use as primary key, or None for default.
93
- """
94
- col_names = list(self.columns.keys())
95
- if primary_col is None:
96
- primary_col = col_names[0]
97
- elif primary_col not in self.columns:
98
- raise ValueError(f"Primary key column '{primary_col}' not found in CSV.")
99
-
100
- cols_def = ", ".join(
101
- f'"{col}" {col_type}{" PRIMARY KEY" if col == primary_col else ""}'
102
- for col, col_type in self.columns.items()
103
- )
104
-
105
- self.cursor.execute(f'CREATE TABLE IF NOT EXISTS "{table}" ({cols_def})')
106
- self.conn.commit()
107
-
108
- def _parse_row_values(self, row: dict, column_types: dict) -> tuple:
109
- """
110
- Convert CSV row string values to types suitable for SQLite.
111
-
112
- :param row: A dictionary from the CSV row.
113
- :param column_types: Mapping of column names to SQLite types.
114
-
115
- :return: Parsed values.
116
- """
117
- values = []
118
- for col, col_type in column_types.items():
119
- val = row.get(col, "")
120
- if val is None or val.strip() == "":
121
- values.append(None)
122
- elif col_type == "REAL":
123
- values.append(float(val))
124
- elif col_type == "INTEGER":
125
- values.append(int(val))
126
- else:
127
- values.append(val)
128
- return tuple(values)
129
-
130
- def import_csv(self, csv_path: Path, pk_column: str = None, sample_size: int = 100):
131
- """
132
- Import a completed_payments.csv file into SQLite.
133
-
134
- Infers column types and logs failed rows. Creates the table if needed.
135
-
136
- :param csv_path: Path to the CSV file.
137
- :param pk_column: (optional) Primary key column name. Defaults to the first column.
138
- :param sample_size: (optional) Number of rows to sample for type inference. Defaults to 100.
139
-
140
- :raises ValueError: If the CSV is empty or contains failed insertions.
141
- """
142
- try:
143
- # First pass: infer schema from sample
144
- with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
145
- reader = DictReader(csvfile)
146
- sample_rows = [row for _, row in zip(range(sample_size), reader)]
147
- if not sample_rows:
148
- raise ValueError("CSV file is empty.")
149
-
150
- # Only infer columns if not already set (i.e., first import)
151
- if not self.columns:
152
- self._infer_columns_from_rows(sample_rows)
153
-
154
- except FileNotFoundError:
155
- print(f"CSV file not found: {csv_path}")
156
- return
157
-
158
- # Create table
159
- self._create_tables(self.table_name, pk_column)
160
-
161
- # Count rows before import
162
- count_before = self.count()
163
-
164
- # Second pass: insert all rows
165
- failed_rows = []
166
- with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
167
- reader = DictReader(csvfile)
168
- column_list = ", ".join(f'"{col}"' for col in self.columns)
169
- placeholders = ", ".join(["?"] * len(self.columns))
170
- insert_stmt = f'INSERT OR IGNORE INTO "{self.table_name}" ({column_list}) \
171
- VALUES ({placeholders})'
172
-
173
- for row in reader:
174
- try:
175
- self.cursor.execute(
176
- insert_stmt, self._parse_row_values(row, self.columns)
177
- )
178
-
179
- except (
180
- IntegrityError,
181
- OperationalError,
182
- DatabaseError,
183
- ValueError,
184
- ) as e:
185
- failed_rows.append((row.copy(), str(e)))
186
-
187
- self.conn.commit()
188
-
189
- print(
190
- f"Inserted {self.count() - count_before} new rows into '{self.table_name}'."
191
- )
192
-
193
- if failed_rows:
194
- print(f"{len(failed_rows)} rows failed to insert:")
195
- for row, error in failed_rows[:5]:
196
- print(f"Failed: {error} | Data: {row}")
197
- if len(failed_rows) > 5:
198
- print(f"... and {len(failed_rows) - 5} more")
199
- raise ValueError(f"Failed to import: {csv_path}")
200
-
201
- def get_row(self, entry_name: str, entry_value: str) -> dict:
202
- """
203
- Retrieve a single row matching column = value (case-insensitive).
204
-
205
- :param entry_name: Column name to filter by.
206
- :param entry_value: Value to match.
207
-
208
- :return: The matching row as a dictionary, or None if not found.
209
- """
210
- if not entry_value:
211
- return None
212
- query = (
213
- f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
214
- )
215
- self.cursor.execute(query, (entry_value,))
216
- row = self.cursor.fetchone()
217
- return dict(row) if row else None
218
-
219
- def get_row_multi(self, match_dict: dict) -> dict:
220
- """
221
- Retrieve the first row matching multiple column = value pairs.
222
-
223
- :param match_dict: Dictionary of column names and values to match.
224
-
225
- :return: The first matching row, or None if not found.
226
- """
227
- conditions = []
228
- values = []
229
- for col, val in match_dict.items():
230
- if val is None or val == "":
231
- conditions.append(f'"{col}" IS NULL')
232
- else:
233
- conditions.append(f'"{col}" = ?')
234
- values.append(
235
- float(val.quantize(Decimal("0.01")))
236
- if isinstance(val, Decimal)
237
- else val
238
- )
239
-
240
- query = f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)} LIMIT 1'
241
- self.cursor.execute(query, values)
242
- return self.cursor.fetchone()
32
+ super().__init__(payment_db_path, db_key, table_name)
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memberjojo
3
- Version: 1.2
3
+ Version: 2.1
4
4
  Summary: memberjojo - tools for working with members.
5
5
  Author-email: Duncan Bellamy <dunk@denkimushi.com>
6
6
  Requires-Python: >=3.8
7
7
  Description-Content-Type: text/markdown
8
8
  License-Expression: MIT
9
9
  License-File: LICENSE
10
+ Requires-Dist: sqlcipher3
10
11
  Requires-Dist: coverage ; extra == "dev"
11
12
  Requires-Dist: flit ; extra == "dev"
12
13
  Requires-Dist: pytest ; extra == "dev"
@@ -23,16 +24,15 @@ Provides-Extra: lint
23
24
 
24
25
  # memberjojo
25
26
 
26
- `memberjojo` is a Python library for managing [Membermojo](http://membermojo.co.uk/)
27
+ `memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
27
28
  data from CSV imports.\
28
- It provides member database interactions, and transaction querying.\
29
- This is done in a local SQLite database, and does not alter anything on Membermojo.\
29
+ It provides member database, and completed payments querying.\
30
+ This is done in a local SQLite database which is encrypted, and does not alter
31
+ anything on Membermojo.\
30
32
  It provides tools to load, and query membership and transaction data efficiently
31
33
  without having to use SQLite directly.\
32
- When importing CSV files existing entries are skipped, so you can just import the
33
- latest download and the local database is updated with new entries.\
34
- All the transaction data is imported into the database,
35
- but currently only a limited amount of member data is imported.
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.\
36
36
 
37
37
  ---
38
38
 
@@ -40,7 +40,21 @@ but currently only a limited amount of member data is imported.
40
40
 
41
41
  Install via `pip`:
42
42
 
43
+ 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)\
46
+
47
+ ```bash
48
+ brew install sqlcipher
49
+ export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
50
+ export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
51
+ pip install memberjojo
52
+ ```
53
+
54
+ Installing via `pip` on ubuntu:
55
+
43
56
  ```bash
57
+ sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
44
58
  pip install memberjojo
45
59
  ```
46
60
 
@@ -64,11 +78,11 @@ from membermojo import Member
64
78
  member_database_path = Path(Path(__file__).parent, "database", "my-members.db")
65
79
  member_csv_path = Path("download", "members.csv")
66
80
 
67
- members = Member(member_database_path)
81
+ members = Member(member_database_path, "My DB Password")
68
82
  members.import_csv(member_csv_path)
69
83
 
70
84
  for member in members:
71
- print(member.first_name, member.last_name, member.member_num)
85
+ print(member.first_name, member.last_name, member.member_number)
72
86
 
73
87
  # Get full name for a given member number
74
88
  found_name = members.get_name(1)
@@ -0,0 +1,11 @@
1
+ memberjojo/__init__.py,sha256=JGhIJ1HGbzrwzF4HsopOdP4eweLjUmFjbIDvMbk1MKI,291
2
+ memberjojo/_version.py,sha256=sdXMKvhafMmgXhpEPosw8Vx1-ID_v-Fo2LNHMrsKPJc,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=3UeBIVtSXBCUpvuGN9fIJ0zgXu6vXhs4wFDHeDnOG0o,8078
7
+ memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
8
+ memberjojo-2.1.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
9
+ memberjojo-2.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
10
+ memberjojo-2.1.dist-info/METADATA,sha256=3acNsTk91IdcxAHJ0-a5mjS74VjittHIcWD17gY3H7g,3602
11
+ memberjojo-2.1.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- memberjojo/__init__.py,sha256=KfW3YTaJkEfdjr1Yf9Lf1jBrBErvCbZ7p-1Y6qzEIOY,303
2
- memberjojo/_version.py,sha256=HQKKxCSMqVTOOMMdZI6WQjmRkHgmuUGY2FWf9ey1h9Y,699
3
- memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
4
- memberjojo/mojo_common.py,sha256=hSMNggC1BOTLOeRQlv4LOC7gqfM0GMRdoHHT5PnuakA,1597
5
- memberjojo/mojo_member.py,sha256=mlv847JW0KaeKJ1MtA_x2TlgUr2q6Pie_LiCyZYRniM,11929
6
- memberjojo/mojo_transaction.py,sha256=772QU3O2xQAzgddRRBqf4UTjXwxKoSZTbHndvMSHcMM,8379
7
- memberjojo-1.2.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
8
- memberjojo-1.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
9
- memberjojo-1.2.dist-info/METADATA,sha256=1__DegGgyFuh4CXIrBEdShdhTLOsZ_Ls-N8AEXI_xoo,3177
10
- memberjojo-1.2.dist-info/RECORD,,