memberjojo 2.1__tar.gz → 2.3__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,13 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memberjojo
3
- Version: 2.1
3
+ Version: 2.3
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
11
10
  Requires-Dist: coverage ; extra == "dev"
12
11
  Requires-Dist: flit ; extra == "dev"
13
12
  Requires-Dist: pytest ; extra == "dev"
@@ -17,44 +16,54 @@ Requires-Dist: sphinx>=7.0 ; extra == "docs"
17
16
  Requires-Dist: sphinx-autodoc-typehints ; extra == "docs"
18
17
  Requires-Dist: black ; extra == "lint"
19
18
  Requires-Dist: pylint ; extra == "lint"
19
+ Requires-Dist: sqlcipher3 ; extra == "sqlcipher"
20
20
  Project-URL: Home, https://github.com/a16bitsysop/memberjojo
21
21
  Provides-Extra: dev
22
22
  Provides-Extra: docs
23
23
  Provides-Extra: lint
24
+ Provides-Extra: sqlcipher
24
25
 
25
26
  # memberjojo
26
27
 
27
28
  `memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
28
29
  data from CSV imports.\
29
30
  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.\
31
+ This is done in a local SQLite database which is optionally encrypted if sqlcipher3
32
+ is installed, and does not alter anything on Membermojo.\
32
33
  It provides tools to load, and query membership and transaction data efficiently
33
34
  without having to use SQLite directly.\
34
35
  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
+ just import the latest download and the local database is updated with a summary
37
+ diff printed out.
38
+
39
+ Using the download_csv function the csv can be downloaded directly into the db,
40
+ which can also be in memory if :memory: is used as the db path.
36
41
 
37
42
  ---
38
43
 
39
44
  ## Installation
40
45
 
41
- Install via `pip`:
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
+ Installing via `pip` on macos with optional `sqlcipher` installed via homebrew:\
47
+ (The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
48
+ `LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
46
49
 
47
50
  ```bash
48
51
  brew install sqlcipher
49
52
  export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
50
53
  export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
51
- pip install memberjojo
54
+ pip install memberjojo[sqlciper]
52
55
  ```
53
56
 
54
57
  Installing via `pip` on ubuntu:
55
58
 
56
59
  ```bash
57
60
  sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
61
+ pip install memberjojo[sqlcipher]
62
+ ```
63
+
64
+ Installing via `pip` without sqlcipher:
65
+
66
+ ```bash
58
67
  pip install memberjojo
59
68
  ```
60
69
 
@@ -3,34 +3,42 @@
3
3
  `memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
4
4
  data from CSV imports.\
5
5
  It provides member database, and completed payments querying.\
6
- This is done in a local SQLite database which is encrypted, and does not alter
7
- anything on Membermojo.\
6
+ This is done in a local SQLite database which is optionally encrypted if sqlcipher3
7
+ is installed, and does not alter 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 with a summary
12
+ diff printed out.
13
+
14
+ Using the download_csv function the csv can be downloaded directly into the db,
15
+ which can also be in memory if :memory: is used as the db path.
12
16
 
13
17
  ---
14
18
 
15
19
  ## Installation
16
20
 
17
- Install via `pip`:
18
-
19
- 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)\
21
+ Installing via `pip` on macos with optional `sqlcipher` installed via homebrew:\
22
+ (The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
23
+ `LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
22
24
 
23
25
  ```bash
24
26
  brew install sqlcipher
25
27
  export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
26
28
  export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
27
- pip install memberjojo
29
+ pip install memberjojo[sqlciper]
28
30
  ```
29
31
 
30
32
  Installing via `pip` on ubuntu:
31
33
 
32
34
  ```bash
33
35
  sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
36
+ pip install memberjojo[sqlcipher]
37
+ ```
38
+
39
+ Installing via `pip` without sqlcipher:
40
+
41
+ ```bash
34
42
  pip install memberjojo
35
43
  ```
36
44
 
@@ -9,13 +9,13 @@ readme = "README.md"
9
9
  license = "MIT"
10
10
  license-files = ["LICENSE"]
11
11
  dynamic = ["version", "description"]
12
- dependencies = ["sqlcipher3"]
13
12
  requires-python = ">=3.8"
14
13
 
15
14
  [project.urls]
16
15
  Home = "https://github.com/a16bitsysop/memberjojo"
17
16
 
18
17
  [project.optional-dependencies]
18
+ sqlcipher = ["sqlcipher3"]
19
19
  dev = [
20
20
  "coverage",
21
21
  "flit",
@@ -10,3 +10,5 @@ except ModuleNotFoundError:
10
10
 
11
11
  from .mojo_member import Member
12
12
  from .mojo_transaction import Transaction
13
+ from .download import Download
14
+ from .url import URL
@@ -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.3'
32
+ __version_tuple__ = version_tuple = (2, 3)
33
33
 
34
- __commit_id__ = commit_id = 'gcf0cc58f7'
34
+ __commit_id__ = commit_id = 'g9a43ccd5d'
@@ -0,0 +1,116 @@
1
+ """
2
+ A class for downloading from membermojo
3
+ """
4
+
5
+ import getpass
6
+ import re
7
+ from http.cookiejar import MozillaCookieJar
8
+ from pathlib import Path
9
+ import requests
10
+
11
+ from .url import URL
12
+
13
+
14
+ class Download:
15
+ """A class for managing Membermojo downloads"""
16
+
17
+ def __init__(self, shortname: str, cookie_jar: MozillaCookieJar):
18
+ """
19
+ Initialise the class
20
+
21
+ :param shortname: the membermojo shortname
22
+ :param cookie_jar: a MozillaCookieJar with the session cookie, or empty to get one
23
+ """
24
+ self.url = URL(shortname)
25
+ self.cookie_jar = cookie_jar
26
+ self.session = requests.Session()
27
+ self.session.cookies = self.cookie_jar
28
+
29
+ def fill_login(self):
30
+ """
31
+ Prompt for email and password to get login data
32
+
33
+ :return: a dict of the login data
34
+ """
35
+ email = input("📧 Enter your Membermojo email: ").strip()
36
+ password = getpass.getpass("🔐 Enter your password: ").strip()
37
+
38
+ # Submit login form (this triggers verification email if needed)
39
+ return {"email": email, "password": password}
40
+
41
+ def mojo_login(self, login_data: dict, email_verify: bool = False):
42
+ """
43
+ Login to membermojo, cookie jar should be saved afterwards with updated cookie
44
+
45
+ :param login_data: a dict containing email and password for requests
46
+ :param email_verify: if True membermojo email verification will be triggered
47
+ if login fails, or no cookie found to create a new session cookie
48
+
49
+ :raises ValueError: If authentication fails and email_verify is False
50
+ """
51
+ if not self.session.cookies:
52
+ if email_verify:
53
+ print("🍪 No cookies saved, triggering email verification.")
54
+ self.trigger_email(login_data)
55
+ else:
56
+ raise ValueError("⚠️ No cookies found — email verification required.")
57
+ self.session.post(self.url.login, data=login_data)
58
+
59
+ # Attempt to access a protected page to verify login worked
60
+ print(f"Verifying login with: {self.url.test}")
61
+ verify_response = self.session.get(self.url.test)
62
+ if "<mm2-loginpage" in verify_response.text:
63
+ if email_verify:
64
+ print("📧 Authentication failed, triggering email verification")
65
+ self.trigger_email(login_data)
66
+ else:
67
+ raise ValueError(
68
+ "⚠️ Authentication Failed — email verification required."
69
+ )
70
+
71
+ def trigger_email(self, login_data: dict):
72
+ """
73
+ Triggers a login verification email, prompts the user for the verification URL,
74
+ and then submits it to complete the login process.
75
+
76
+ :param login_data: A dictionary containing login credentials (e.g., email)
77
+
78
+ :raises: ValueError: If a CSRF token cannot be found or if the login form submission fails
79
+ """
80
+ self.session.cookies.clear()
81
+ response = self.session.post(self.url.login, data=login_data)
82
+
83
+ if "check your email" in response.text.lower() or response.ok:
84
+ print("✅ Login submitted — check your inbox for a verification link.")
85
+
86
+ # Get CSRF token from homepage
87
+ homepage = self.session.get(self.url.base_url)
88
+ match = re.search(r'"csrf_token":"([^"]+)"', homepage.text)
89
+ if not match:
90
+ raise ValueError("❌ Could not find CSRF token.")
91
+
92
+ csrf_token = match.group(1)
93
+ print(f"✅ CSRF token: {csrf_token}")
94
+
95
+ # Ask user for the verification link
96
+ verification_url = input(
97
+ "🔗 Paste the verification URL from the email: "
98
+ ).strip()
99
+
100
+ # Submit the verification request
101
+ verify_response = self.session.post(
102
+ verification_url, data={"csrf_token": csrf_token}
103
+ )
104
+
105
+ # Output
106
+ if verify_response.ok:
107
+ print("✅ Verification successful. You're now logged in.")
108
+ else:
109
+ print("⚠️ Verification may have failed.")
110
+ verify_html = Path("verify.html")
111
+ with verify_html.open("w", encoding="UTF-8") as f:
112
+ f.write(verify_response.text)
113
+
114
+ else:
115
+ print(response.text)
116
+ raise ValueError("❌ Failed to submit login form.")
@@ -0,0 +1,346 @@
1
+ """
2
+ MojoSkel base class
3
+
4
+ This module provides a common base class (`MojoSkel`) for other `memberjojo` modules
5
+ It includes helper methods for working with SQLite databases
6
+ """
7
+
8
+ from dataclasses import make_dataclass
9
+ from decimal import Decimal, InvalidOperation
10
+ from pathlib import Path
11
+ from pprint import pprint
12
+ from typing import Any, Iterator, List, Type, Union
13
+
14
+ import requests
15
+
16
+ try:
17
+ from sqlcipher3 import dbapi2 as sqlite3
18
+
19
+ HAS_SQLCIPHER = True
20
+ except ImportError:
21
+ import sqlite3 # stdlib
22
+
23
+ HAS_SQLCIPHER = False
24
+
25
+ from . import mojo_loader
26
+ from .sql_query import Like
27
+
28
+
29
+ class MojoSkel:
30
+ """
31
+ Establishes a connection to a SQLite database and provides helper methods
32
+ for querying tables
33
+ """
34
+
35
+ def __init__(self, db_path: str, db_key: str, table_name: str):
36
+ """
37
+ Initialize the MojoSkel class
38
+
39
+ Connects to the SQLite database and sets the row factory for
40
+ dictionary-style access to columns.
41
+
42
+ :param db_path: Path to the SQLite database file
43
+ :param db_key: key to unlock the encrypted sqlite database,
44
+ unencrypted if sqlcipher3 not installed or unset
45
+ :param table_name: Name of the table to operate on, or create when importing
46
+ """
47
+ self.db_path = db_path
48
+ self.table_name = table_name
49
+ self.db_key = db_key
50
+ self.debug = False
51
+
52
+ # Open connection
53
+ self.conn = sqlite3.connect(self.db_path) # pylint: disable=no-member
54
+ self.conn.row_factory = sqlite3.Row # pylint: disable=no-member
55
+ self.cursor = self.conn.cursor()
56
+
57
+ if HAS_SQLCIPHER and db_key:
58
+ # Apply SQLCipher key
59
+ self.cursor.execute(f"PRAGMA key='{db_key}'")
60
+ self.cursor.execute("PRAGMA cipher_compatibility = 4")
61
+ print(
62
+ "Cipher:", self.cursor.execute("PRAGMA cipher_version;").fetchone()[0]
63
+ )
64
+ print(f"Encrypted database {self.db_path} loaded securely.")
65
+ else:
66
+ print(f"Unencrypted database {self.db_path} loaded securely.")
67
+
68
+ # After table exists (or after import), build the dataclass
69
+ if self.table_exists():
70
+ self.row_class = self._build_dataclass_from_table()
71
+ else:
72
+ self.row_class = None
73
+
74
+ def __iter__(self) -> Iterator[Any]:
75
+ """
76
+ Allow iterating over the class, by outputing all members
77
+ """
78
+ if not self.row_class:
79
+ raise RuntimeError("Table not loaded yet — no dataclass available")
80
+ return self._iter_rows()
81
+
82
+ def _row_to_obj(self, row: sqlite3.Row) -> Type[Any]:
83
+ """
84
+ Convert an sqlite3 row into a dataclass object
85
+
86
+ :param row: The sqlite3 row to convert
87
+
88
+ :return: A dataclass object of the row
89
+ """
90
+ row_dict = dict(row)
91
+
92
+ # Convert REAL → Decimal (including numeric strings)
93
+ for k, v in row_dict.items():
94
+ if isinstance(v, float):
95
+ row_dict[k] = Decimal(str(v))
96
+ elif isinstance(v, str):
97
+ try:
98
+ row_dict[k] = Decimal(v)
99
+ except InvalidOperation:
100
+ pass
101
+
102
+ return self.row_class(**row_dict)
103
+
104
+ def _iter_rows(self) -> Iterator[Any]:
105
+ """
106
+ Iterate over table rows and yield dynamically-created dataclass objects
107
+ Converts REAL columns to Decimal automatically
108
+
109
+ :return: An interator of dataclass objects for rows
110
+ """
111
+
112
+ sql = f'SELECT * FROM "{self.table_name}"'
113
+
114
+ cur = self.conn.cursor()
115
+ cur.execute(sql)
116
+
117
+ for row in cur.fetchall():
118
+ yield self._row_to_obj(row)
119
+
120
+ def _build_dataclass_from_table(self) -> Type[Any]:
121
+ """
122
+ Dynamically create a dataclass from the table schema
123
+ INTEGER → int
124
+ REAL → Decimal
125
+ TEXT → str
126
+
127
+ :return: A dataclass built from the table columns and types
128
+
129
+ :raises ValueError: If no table
130
+ """
131
+ self.cursor.execute(f'PRAGMA table_info("{self.table_name}")')
132
+ cols = self.cursor.fetchall()
133
+
134
+ if not cols:
135
+ raise ValueError(f"Table '{self.table_name}' does not exist")
136
+
137
+ fields = []
138
+ for _cid, name, col_type, _notnull, _dflt, _pk in cols:
139
+ t = col_type.upper()
140
+
141
+ if t.startswith("INT"):
142
+ py_type = int
143
+ elif t.startswith("REAL") or t.startswith("NUM") or t.startswith("DEC"):
144
+ py_type = Decimal
145
+ else:
146
+ py_type = str
147
+
148
+ fields.append((name, py_type))
149
+
150
+ return make_dataclass(f"{self.table_name}_Row", fields)
151
+
152
+ def rename_old_table(self, existing: bool) -> str:
153
+ """
154
+ If there was an exising table rename for comparison
155
+
156
+ :param existing: bool for table exists
157
+
158
+ :return: the old table name
159
+ """
160
+ old_table = f"{self.table_name}_old"
161
+ self.conn.execute(f"DROP TABLE IF EXISTS {old_table}")
162
+ # Preserve existing table
163
+ if existing:
164
+ self.conn.execute(f"ALTER TABLE {self.table_name} RENAME TO {old_table}")
165
+ return old_table
166
+
167
+ def print_diff(self, old_table: str):
168
+ """
169
+ Print out diff between old and new db
170
+
171
+ :param old_table: The name the existing table was renamed to
172
+ """
173
+ try:
174
+ # Diff old vs new (SQLCipher → sqlite3 → dataclasses)
175
+ diff_rows = mojo_loader.diff_cipher_tables(
176
+ self.conn,
177
+ new_table=self.table_name,
178
+ old_table=old_table,
179
+ )
180
+
181
+ if diff_rows:
182
+ for diff in diff_rows:
183
+ # diff is a DiffRow dataclass
184
+ print(diff.diff_type, diff.preview)
185
+
186
+ finally:
187
+ # Cleanup old table (always)
188
+ self.conn.execute(f"DROP TABLE {old_table}")
189
+
190
+ def download_csv(self, session: requests.Session, url: str):
191
+ """
192
+ Download the CSV from url and import into the sqlite database
193
+ If a previous table exists, generate a diff
194
+
195
+ :param session: Requests session to use for download
196
+ :param url: url of the csv to download
197
+ """
198
+ had_existing = self.table_exists()
199
+ old_table = self.rename_old_table(had_existing)
200
+
201
+ # Download CSV as new table
202
+ mojo_loader.download_csv_helper(self.conn, self.table_name, url, session)
203
+ self.row_class = self._build_dataclass_from_table()
204
+
205
+ if not had_existing:
206
+ return
207
+
208
+ self.print_diff(old_table)
209
+
210
+ def import_csv(self, csv_path: Path):
211
+ """
212
+ Import the passed CSV into the encrypted sqlite database
213
+
214
+ :param csv_path: Path like path of csv file
215
+ """
216
+ had_existing = self.table_exists()
217
+ old_table = self.rename_old_table(had_existing)
218
+
219
+ # Import CSV as new table
220
+ mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
221
+ self.row_class = self._build_dataclass_from_table()
222
+
223
+ if not had_existing:
224
+ return
225
+
226
+ self.print_diff(old_table)
227
+
228
+ def show_table(self, limit: int = 2):
229
+ """
230
+ Print the first few rows of the table as dictionaries
231
+
232
+ :param limit: (optional) Number of rows to display. Defaults to 2
233
+ """
234
+ if self.table_exists():
235
+ self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
236
+ rows = self.cursor.fetchall()
237
+
238
+ else:
239
+ print("(No data)")
240
+ return
241
+
242
+ for row in rows:
243
+ print(dict(row))
244
+
245
+ def count(self) -> int:
246
+ """
247
+ :return: count of the number of rows in the table, or 0 if no table
248
+ """
249
+ if self.table_exists():
250
+ self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
251
+ result = self.cursor.fetchone()
252
+ return result[0] if result else 0
253
+
254
+ return 0
255
+
256
+ def get_row(
257
+ self, entry_name: str, entry_value: str, only_one: bool = True
258
+ ) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
259
+ """
260
+ Retrieve a single row matching column = value (case-insensitive)
261
+
262
+ :param entry_name: Column name to filter by
263
+ :param entry_value: Value to match
264
+ :param only_one: If True (default), return the first matching row
265
+ If False, return a list of all matching rows
266
+
267
+ :return:
268
+ - If only_one=True → a single sqlite3.Row or None
269
+ - If only_one=False → list of sqlite3.Row (may be empty)
270
+ """
271
+ match_dict = {f"{entry_name}": entry_value}
272
+
273
+ return self.get_row_multi(match_dict, only_one)
274
+
275
+ def get_row_multi(
276
+ self, match_dict: dict, only_one: bool = True
277
+ ) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
278
+ """
279
+ Retrieve one or many rows matching multiple column=value pairs
280
+
281
+ :param match_dict: Dictionary of column names and values to match
282
+ :param only_one: If True (default), return the first matching row
283
+ If False, return a list of all matching rows
284
+
285
+ :return:
286
+ - If only_one=True → a single sqlite3.Row or None
287
+ - If only_one=False → list of sqlite3.Row (may be empty)
288
+ """
289
+ conditions = []
290
+ values = []
291
+
292
+ for col, val in match_dict.items():
293
+ if val is None or val == "":
294
+ conditions.append(f'"{col}" IS NULL')
295
+ elif isinstance(val, Like):
296
+ conditions.append(f'LOWER("{col}") LIKE LOWER(?)')
297
+ values.append(val.pattern)
298
+ elif isinstance(val, (tuple, list)) and len(val) == 2:
299
+ lower, upper = val
300
+ if lower is not None and upper is not None:
301
+ conditions.append(f'"{col}" BETWEEN ? AND ?')
302
+ values.extend([lower, upper])
303
+ elif lower is not None:
304
+ conditions.append(f'"{col}" >= ?')
305
+ values.append(lower)
306
+ elif upper is not None:
307
+ conditions.append(f'"{col}" <= ?')
308
+ values.append(upper)
309
+ else:
310
+ # Both are None, effectively no condition on this column
311
+ pass
312
+ else:
313
+ conditions.append(f'"{col}" = ?')
314
+ values.append(
315
+ float(val.quantize(Decimal("0.01")))
316
+ if isinstance(val, Decimal)
317
+ else val
318
+ )
319
+
320
+ # Base query string
321
+ base_query = (
322
+ f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
323
+ )
324
+ if self.debug:
325
+ print("Sql:")
326
+ pprint(base_query)
327
+
328
+ if only_one:
329
+ query = base_query + " LIMIT 1"
330
+ self.cursor.execute(query, values)
331
+ if row := self.cursor.fetchone():
332
+ return self._row_to_obj(row)
333
+ return None
334
+
335
+ self.cursor.execute(base_query, values)
336
+ return [self._row_to_obj(row) for row in self.cursor.fetchall()]
337
+
338
+ def table_exists(self) -> bool:
339
+ """
340
+ Return True or False if a table exists
341
+ """
342
+ self.cursor.execute(
343
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
344
+ (self.table_name,),
345
+ )
346
+ return self.cursor.fetchone() is not None