memberjojo 1.1__py3-none-any.whl → 2.0__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
@@ -1,7 +1,14 @@
1
1
  # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
3
 
4
- __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
5
12
 
6
13
  TYPE_CHECKING = False
7
14
  if TYPE_CHECKING:
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
9
16
  from typing import Union
10
17
 
11
18
  VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
12
20
  else:
13
21
  VERSION_TUPLE = object
22
+ COMMIT_ID = object
14
23
 
15
24
  version: str
16
25
  __version__: str
17
26
  __version_tuple__: VERSION_TUPLE
18
27
  version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
19
30
 
20
- __version__ = version = '1.1'
21
- __version_tuple__ = version_tuple = (1, 1)
31
+ __version__ = version = '2.0'
32
+ __version_tuple__ = version_tuple = (2, 0)
33
+
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()
@@ -124,7 +64,9 @@ class Member(MojoSkel):
124
64
 
125
65
  def get_number(self, full_name: str, found_error: bool = False) -> Optional[int]:
126
66
  """
127
- Find a member number by full name (tries first and last, and then middle last if 3 words).
67
+ Find a member number by passed full_name.
68
+ Tries first and last, and then middle last if 3 words,
69
+ Then initial of first name if initials passed.
128
70
 
129
71
  :param full_name: Full name of the member.
130
72
  :param found_error: (optional) Raise ValueError if not found.
@@ -133,105 +75,163 @@ class Member(MojoSkel):
133
75
 
134
76
  :raises ValueError: If not found and `found_error` is True.
135
77
  """
136
- member_num = None
137
- try_names = []
78
+ result = self.get_mojo_name(full_name, found_error)
79
+ if result:
80
+ return self.get_number_first_last(result[0], result[1])
81
+ return None
82
+
83
+ def _lookup_exact(self, first_name: str, last_name: str) -> Optional[tuple]:
84
+ """
85
+ Lookup first_name and last_name in the member database, return found name or none
86
+
87
+ :param first_name: First name to lookup
88
+ :param last_name: Last name to lookup
89
+
90
+ :return: Name on membermojo or None
91
+ """
92
+ sql = f"""
93
+ SELECT "first_name", "last_name"
94
+ FROM "{self.table_name}"
95
+ WHERE LOWER("first_name") = LOWER(?)
96
+ AND LOWER("last_name") = LOWER(?)
97
+ """
98
+ self.cursor.execute(sql, (first_name, last_name))
99
+ row = self.cursor.fetchone()
100
+ return (row["first_name"], row["last_name"]) if row else None
101
+
102
+ def _lookup_initial(self, letter: str, last_name: str) -> Optional[tuple]:
103
+ """
104
+ Lookup Initial and last_name in the member database, return found name or none
105
+
106
+ :param letter: initial to search for
107
+ :param last_name: last name to search for
108
+
109
+ :return: Name on membermojo or None
110
+ """
111
+ sql = f"""
112
+ SELECT "first_name", "last_name"
113
+ FROM "{self.table_name}"
114
+ WHERE LOWER("first_name") LIKE LOWER(?) || '%'
115
+ AND LOWER("last_name") = LOWER(?)
116
+ LIMIT 1
117
+ """
118
+ self.cursor.execute(sql, (letter, last_name))
119
+ row = self.cursor.fetchone()
120
+ return (row["first_name"], row["last_name"]) if row else None
121
+
122
+ def get_mojo_name(
123
+ self, full_name: str, found_error: bool = False
124
+ ) -> Optional[tuple]:
125
+ """
126
+ Resolve a member name from a free-text full name.
127
+ Search order:
128
+ 1. first + last
129
+ 2. middle + last (if three parts)
130
+ 3. initial 1st letter + last
131
+ 4. initial 2nd letter + last (for two-letter initials)
132
+ Returns (first_name, last_name) or None.
133
+
134
+ :param full_name: Full name of the member to find.
135
+ :param found_error: (optional) Raise ValueError if not found.
136
+
137
+ :return: Membermojo name if found, else None.
138
+
139
+ :raises ValueError: If not found and `found_error` is True.
140
+ """
141
+
138
142
  parts = full_name.strip().split()
139
- # Try first + last
140
- if len(parts) >= 2:
141
- first_name = parts[0]
142
- last_name = parts[-1]
143
- try_names.append(f"<{first_name} {last_name}>")
144
- member_num = self.get_number_first_last(first_name, last_name)
145
-
146
- # Try middle + last if 3 parts and first+last failed
147
- if not member_num and len(parts) == 3:
148
- first_name = parts[1]
149
- last_name = parts[2]
150
- try_names.append(f"<{first_name} {last_name}>")
151
- member_num = self.get_number_first_last(first_name, last_name)
152
-
153
- if not member_num and found_error:
154
- tried = ", ".join(try_names) if try_names else "No valid names to find"
143
+ tried = []
144
+
145
+ # If only one one word passed, fail early
146
+ if len(parts) < 2:
147
+ if found_error:
148
+ raise ValueError(f"❌ Cannot extract name from: {full_name}")
149
+ return None
150
+
151
+ # ----------------------------
152
+ # 1. Try direct first + last
153
+ # ----------------------------
154
+ tried.append(f"{parts[0]} {parts[-1]}")
155
+
156
+ result = self._lookup_exact(parts[0], parts[-1])
157
+ if result:
158
+ return result
159
+
160
+ # ----------------------------
161
+ # 2. Try middle + last and build initials if no match
162
+ # ----------------------------
163
+ if len(parts) == 3:
164
+ tried.append(f"{parts[1]} {parts[2]}")
165
+
166
+ result = self._lookup_exact(parts[1], parts[2])
167
+ if result:
168
+ return result
169
+
170
+ # First letter of first + first letter of middle
171
+ initials = parts[0][0].upper() + parts[1][0].upper()
172
+ else:
173
+ # Only first letter of first name
174
+ initials = parts[0][0].upper()
175
+
176
+ # ------------------------------------------------
177
+ # Initial fallback lookups
178
+ # ------------------------------------------------
179
+
180
+ # 3. Try first initial + last name
181
+ first_initial = initials[0]
182
+ tried.append(f"{first_initial} {parts[-1]}")
183
+ result = self._lookup_initial(first_initial, parts[-1])
184
+ if result:
185
+ return result
186
+
187
+ # 4. Try second initial + last name (e.g., for JA or AM)
188
+ if len(initials) > 1:
189
+ second_initial = initials[1]
190
+ tried.append(f"{second_initial} {parts[-1]}")
191
+ result = self._lookup_initial(second_initial, parts[-1])
192
+ if result:
193
+ return result
194
+
195
+ # ----------------------------
196
+ # 5. No match
197
+ # ----------------------------
198
+ if found_error:
155
199
  raise ValueError(
156
200
  f"❌ Cannot find {full_name} in member database. Tried: {tried}"
157
201
  )
158
- return member_num
159
202
 
160
- def get_name(self, member_number: int) -> Optional[str]:
203
+ return None
204
+
205
+ def get_first_last_name(self, member_number: int) -> Optional[str]:
161
206
  """
162
207
  Get full name for a given member number.
163
208
 
164
209
  :param member_number: Member number to look up.
165
210
 
166
- :return: Full name as "First Last", or None if not found.
211
+ :return: Full name as tuple, or None if not found.
167
212
  """
168
213
  sql = f"""
169
- SELECT first_name, last_name
214
+ SELECT "first_name", "last_name"
170
215
  FROM "{self.table_name}"
171
- WHERE member_number = ?
216
+ WHERE "member_number" = ?
172
217
  """
173
218
  self.cursor.execute(sql, (member_number,))
174
219
  result = self.cursor.fetchone()
175
220
 
176
- if result:
177
- first_name, last_name = result
178
- return f"{first_name} {last_name}"
179
- return None
221
+ return result if result else None
180
222
 
181
- def _add(self, member: MemberData):
182
- """
183
- Insert a member into the database if not already present.
184
-
185
- :param member: The member to add.
186
- """
187
- sql = f"""INSERT OR ABORT INTO "{self.table_name}"
188
- (member_number, title, first_name, last_name, membermojo_id, short_url)
189
- VALUES (?, ?, ?, ?, ?, ?)"""
190
-
191
- try:
192
- self.cursor.execute(
193
- sql,
194
- (
195
- member.member_num,
196
- member.title,
197
- member.first_name,
198
- member.last_name,
199
- member.membermojo_id,
200
- member.short_url,
201
- ),
202
- )
203
- self.conn.commit()
204
- print(
205
- f"Created user {member.member_num}: {member.first_name} {member.last_name}"
206
- )
207
- except sqlite3.IntegrityError:
208
- pass
209
-
210
- def import_csv(self, csv_path: Path):
223
+ def get_name(self, member_number: int) -> Optional[str]:
211
224
  """
212
- Load members from a Membermojo CSV file and insert into the database.
225
+ Get full name for a given member number.
213
226
 
214
- :param csv_path: Path to the CSV file.
227
+ :param member_number: Member number to look up.
215
228
 
216
- Notes:
217
- Only adds members not already in the database (INSERT OR ABORT).
229
+ :return: Full name as "First Last", or None if not found.
218
230
  """
219
- print(f"Using SQLite database version {sqlite3.sqlite_version}")
220
- self._create_tables()
221
231
 
222
- try:
223
- with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
224
- mojo_reader = DictReader(csvfile)
232
+ result = self.get_first_last_name(member_number)
225
233
 
226
- for row in mojo_reader:
227
- member = MemberData(
228
- member_num=int(row["Member number"]),
229
- title=row["Title"].strip(),
230
- first_name=row["First name"].strip(),
231
- last_name=row["Last name"].strip(),
232
- membermojo_id=int(row["membermojo ID"]),
233
- short_url=row["Short URL"].strip(),
234
- )
235
- self._add(member)
236
- except FileNotFoundError:
237
- print(f"CSV file not found: {csv_path}")
234
+ if result:
235
+ first_name, last_name = result
236
+ return f"{first_name} {last_name}"
237
+ return None
@@ -5,12 +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
-
13
- from .config import CSV_ENCODING # import encoding from config.py
14
8
  from .mojo_common import MojoSkel
15
9
 
16
10
 
@@ -23,215 +17,16 @@ class Transaction(MojoSkel):
23
17
 
24
18
  :param payment_db_path: Path to the SQLite database.
25
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.
26
21
  """
27
22
 
28
- 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
+ ):
29
29
  """
30
30
  Initialize the Transaction object.
31
31
  """
32
- super().__init__(payment_db_path, table_name)
33
- self.columns = {}
34
-
35
- def _guess_type(self, value: any) -> str:
36
- """
37
- Guess the SQLite data type of a CSV field value.
38
-
39
- :param value: The value from a CSV field.
40
-
41
- :return: One of 'INTEGER', 'REAL', or 'TEXT'.
42
- """
43
- if value is None:
44
- return "TEXT"
45
-
46
- if isinstance(value, str):
47
- value = value.strip()
48
- if value == "":
49
- return "TEXT"
50
-
51
- try:
52
- int(value)
53
- return "INTEGER"
54
- except (ValueError, TypeError):
55
- try:
56
- float(value)
57
- return "REAL"
58
- except (ValueError, TypeError):
59
- return "TEXT"
60
-
61
- def _infer_columns_from_rows(self, rows: list[dict]):
62
- """
63
- Infer SQLite column types based on sample CSV data.
64
-
65
- :param rows: Sample rows from CSV to analyze.
66
- """
67
- type_counters = defaultdict(Counter)
68
-
69
- for row in rows:
70
- for key, value in row.items():
71
- guessed_type = self._guess_type(value)
72
- type_counters[key][guessed_type] += 1
73
-
74
- self.columns = {}
75
- for col, counter in type_counters.items():
76
- if counter["TEXT"] == 0:
77
- if counter["REAL"] > 0:
78
- self.columns[col] = "REAL"
79
- else:
80
- self.columns[col] = "INTEGER"
81
- else:
82
- self.columns[col] = "TEXT"
83
-
84
- print("Inferred columns:", self.columns)
85
-
86
- def _create_tables(self, table: str, primary_col: str):
87
- """
88
- Create the table if it doesn't exist, using inferred schema.
89
-
90
- :param table: Table name.
91
- :param primary_col: Column to use as primary key, or None for default.
92
- """
93
- col_names = list(self.columns.keys())
94
- if primary_col is None:
95
- primary_col = col_names[0]
96
- elif primary_col not in self.columns:
97
- raise ValueError(f"Primary key column '{primary_col}' not found in CSV.")
98
-
99
- cols_def = ", ".join(
100
- f'"{col}" {col_type}{" PRIMARY KEY" if col == primary_col else ""}'
101
- for col, col_type in self.columns.items()
102
- )
103
-
104
- self.cursor.execute(f'CREATE TABLE IF NOT EXISTS "{table}" ({cols_def})')
105
- self.conn.commit()
106
-
107
- def _parse_row_values(self, row: dict, column_types: dict) -> tuple:
108
- """
109
- Convert CSV row string values to types suitable for SQLite.
110
-
111
- :param row: A dictionary from the CSV row.
112
- :param column_types: Mapping of column names to SQLite types.
113
-
114
- :return: Parsed values.
115
- """
116
- values = []
117
- for col, col_type in column_types.items():
118
- val = row.get(col, "")
119
- if val is None or val.strip() == "":
120
- values.append(None)
121
- elif col_type == "REAL":
122
- values.append(float(val))
123
- elif col_type == "INTEGER":
124
- values.append(int(val))
125
- else:
126
- values.append(val)
127
- return tuple(values)
128
-
129
- def import_csv(self, csv_path: Path, pk_column: str = None, sample_size: int = 100):
130
- """
131
- Import a completed_payments.csv file into SQLite.
132
-
133
- Infers column types and logs failed rows. Creates the table if needed.
134
-
135
- :param csv_path: Path to the CSV file.
136
- :param pk_column: (optional) Primary key column name. Defaults to the first column.
137
- :param sample_size: (optional) Number of rows to sample for type inference. Defaults to 100.
138
-
139
- :raises ValueError: If the CSV is empty or contains failed insertions.
140
- """
141
- try:
142
- # First pass: infer schema from sample
143
- with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
144
- reader = DictReader(csvfile)
145
- sample_rows = [row for _, row in zip(range(sample_size), reader)]
146
- if not sample_rows:
147
- raise ValueError("CSV file is empty.")
148
-
149
- # Only infer columns if not already set (i.e., first import)
150
- if not self.columns:
151
- self._infer_columns_from_rows(sample_rows)
152
-
153
- except FileNotFoundError:
154
- print(f"CSV file not found: {csv_path}")
155
- return
156
-
157
- # Create table
158
- self._create_tables(self.table_name, pk_column)
159
-
160
- # Count rows before import
161
- count_before = self.count()
162
-
163
- # Second pass: insert all rows
164
- failed_rows = []
165
- with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
166
- reader = DictReader(csvfile)
167
- column_list = ", ".join(f'"{col}"' for col in self.columns)
168
- placeholders = ", ".join(["?"] * len(self.columns))
169
- insert_stmt = f'INSERT OR IGNORE INTO "{self.table_name}" ({column_list}) \
170
- VALUES ({placeholders})'
171
-
172
- for row in reader:
173
- try:
174
- self.cursor.execute(
175
- insert_stmt, self._parse_row_values(row, self.columns)
176
- )
177
-
178
- except (
179
- IntegrityError,
180
- OperationalError,
181
- DatabaseError,
182
- ValueError,
183
- ) as e:
184
- failed_rows.append((row.copy(), str(e)))
185
-
186
- self.conn.commit()
187
-
188
- print(
189
- f"Inserted {self.count() - count_before} new rows into '{self.table_name}'."
190
- )
191
-
192
- if failed_rows:
193
- print(f"{len(failed_rows)} rows failed to insert:")
194
- for row, error in failed_rows[:5]:
195
- print(f"Failed: {error} | Data: {row}")
196
- if len(failed_rows) > 5:
197
- print(f"... and {len(failed_rows) - 5} more")
198
- raise ValueError(f"Failed to import: {csv_path}")
199
-
200
- def get_row(self, entry_name: str, entry_value: str) -> dict:
201
- """
202
- Retrieve a single row matching column = value (case-insensitive).
203
-
204
- :param entry_name: Column name to filter by.
205
- :param entry_value: Value to match.
206
-
207
- :return: The matching row as a dictionary, or None if not found.
208
- """
209
- if not entry_value:
210
- return None
211
- query = (
212
- f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
213
- )
214
- self.cursor.execute(query, (entry_value,))
215
- row = self.cursor.fetchone()
216
- return dict(row) if row else None
217
-
218
- def get_row_multi(self, match_dict: dict) -> dict:
219
- """
220
- Retrieve the first row matching multiple column = value pairs.
221
-
222
- :param match_dict: Dictionary of column names and values to match.
223
-
224
- :return: The first matching row, or None if not found.
225
- """
226
- conditions = []
227
- values = []
228
- for col, val in match_dict.items():
229
- if val is None or val == "":
230
- conditions.append(f'"{col}" IS NULL')
231
- else:
232
- conditions.append(f'"{col}" = ?')
233
- values.append(val)
234
-
235
- query = f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)} LIMIT 1'
236
- self.cursor.execute(query, values)
237
- 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.1
3
+ Version: 2.0
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=BQP3Alj0qj-vHGLR2CI-B53HBhY5UAffEPyInuYZ0jU,699
3
+ memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
4
+ memberjojo/mojo_common.py,sha256=uisTDjc7SCcTIecF35issytUzD8cHn3Tx7YbDZkp64E,7402
5
+ memberjojo/mojo_loader.py,sha256=PZRrdjCCqatrGbqyqPoI5Pg6IWmeVduqyE8zV9GwKlg,3935
6
+ memberjojo/mojo_member.py,sha256=1Tei14y3opJ4To0KerUwJ7_kidFB87jKWZ7KjDN3B4k,8088
7
+ memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
8
+ memberjojo-2.0.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
9
+ memberjojo-2.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
10
+ memberjojo-2.0.dist-info/METADATA,sha256=rGQLFERi8EpqH5oowAatO5xIOtaNc987BBOH4DY0zc4,3602
11
+ memberjojo-2.0.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- memberjojo/__init__.py,sha256=KfW3YTaJkEfdjr1Yf9Lf1jBrBErvCbZ7p-1Y6qzEIOY,303
2
- memberjojo/_version.py,sha256=AAsnOeBTLGhtISZFJd737cAtw6_DnK8inDZsYGvd-x0,506
3
- memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
4
- memberjojo/mojo_common.py,sha256=hSMNggC1BOTLOeRQlv4LOC7gqfM0GMRdoHHT5PnuakA,1597
5
- memberjojo/mojo_member.py,sha256=IuZMvERjmrBoz_rQIr9SBIDEC0PqEPiJX2SvA69paSU,8018
6
- memberjojo/mojo_transaction.py,sha256=rtyRfRPexFlnC9LFdAQe0yy56AaXeunyF6tSobG-Ipo,8203
7
- memberjojo-1.1.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
8
- memberjojo-1.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
9
- memberjojo-1.1.dist-info/METADATA,sha256=2FrAn6BI1yO3pYJYdAWCdNGJGudsmpAK8yJX20XMC8c,3177
10
- memberjojo-1.1.dist-info/RECORD,,