memberjojo 2.2__py3-none-any.whl → 3.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
@@ -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
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 = '2.2'
32
- __version_tuple__ = version_tuple = (2, 2)
31
+ __version__ = version = '3.0'
32
+ __version_tuple__ = version_tuple = (3, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
memberjojo/download.py ADDED
@@ -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.")
memberjojo/mojo_common.py CHANGED
@@ -1,53 +1,69 @@
1
1
  """
2
2
  MojoSkel base class
3
3
 
4
- This module provides a common base class (`MojoSkel`) for other `memberjojo` modules.
5
- It includes helper methods for working with SQLite databases.
4
+ This module provides a common base class (`MojoSkel`) for other `memberjojo` modules
5
+ It includes helper methods for working with SQLite databases
6
6
  """
7
7
 
8
- # pylint: disable=no-member
9
-
10
8
  from dataclasses import make_dataclass
11
9
  from decimal import Decimal, InvalidOperation
12
10
  from pathlib import Path
13
- from typing import Union, List
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
14
22
 
15
- from sqlcipher3 import dbapi2 as sqlite3
23
+ HAS_SQLCIPHER = False
16
24
 
17
25
  from . import mojo_loader
26
+ from .sql_query import Like
18
27
 
19
28
 
20
29
  class MojoSkel:
21
30
  """
22
31
  Establishes a connection to a SQLite database and provides helper methods
23
- for querying tables.
32
+ for querying tables
24
33
  """
25
34
 
26
35
  def __init__(self, db_path: str, db_key: str, table_name: str):
27
36
  """
28
- Initialize the MojoSkel class.
37
+ Initialize the MojoSkel class
29
38
 
30
39
  Connects to the SQLite database and sets the row factory for
31
40
  dictionary-style access to columns.
32
41
 
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.
35
- :param table_name: Name of the table to operate on, or create when importing.
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
36
46
  """
37
47
  self.db_path = db_path
38
48
  self.table_name = table_name
39
49
  self.db_key = db_key
50
+ self.debug = False
40
51
 
41
52
  # Open connection
42
- self.conn = sqlite3.connect(self.db_path)
43
- self.conn.row_factory = sqlite3.Row
53
+ self.conn = sqlite3.connect(self.db_path) # pylint: disable=no-member
54
+ self.conn.row_factory = sqlite3.Row # pylint: disable=no-member
44
55
  self.cursor = self.conn.cursor()
45
56
 
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.")
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.")
51
67
 
52
68
  # After table exists (or after import), build the dataclass
53
69
  if self.table_exists():
@@ -55,18 +71,42 @@ class MojoSkel:
55
71
  else:
56
72
  self.row_class = None
57
73
 
58
- def __iter__(self):
74
+ def __iter__(self) -> Iterator[Any]:
59
75
  """
60
- Allow iterating over the class, by outputing all members.
76
+ Allow iterating over the class, by outputing all members
61
77
  """
62
78
  if not self.row_class:
63
79
  raise RuntimeError("Table not loaded yet — no dataclass available")
64
80
  return self._iter_rows()
65
81
 
66
- def _iter_rows(self):
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]:
67
105
  """
68
- Iterate over table rows and yield dynamically-created dataclass objects.
69
- Converts REAL columns to Decimal automatically.
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
70
110
  """
71
111
 
72
112
  sql = f'SELECT * FROM "{self.table_name}"'
@@ -75,29 +115,18 @@ class MojoSkel:
75
115
  cur.execute(sql)
76
116
 
77
117
  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):
118
+ yield self._row_to_obj(row)
119
+
120
+ def _build_dataclass_from_table(self) -> Type[Any]:
94
121
  """
95
- Dynamically create a dataclass from the table schema.
122
+ Dynamically create a dataclass from the table schema
96
123
  INTEGER → int
97
124
  REAL → Decimal
98
125
  TEXT → str
99
126
 
100
- :return: A dataclass built from the table columns and types.
127
+ :return: A dataclass built from the table columns and types
128
+
129
+ :raises ValueError: If no table
101
130
  """
102
131
  self.cursor.execute(f'PRAGMA table_info("{self.table_name}")')
103
132
  cols = self.cursor.fetchall()
@@ -120,30 +149,29 @@ class MojoSkel:
120
149
 
121
150
  return make_dataclass(f"{self.table_name}_Row", fields)
122
151
 
123
- def import_csv(self, csv_path: Path):
152
+ def rename_old_table(self, existing: bool) -> str:
124
153
  """
125
- Import the passed CSV into the encrypted sqlite database.
126
- If a previous table exists, generate a diff using
127
- mojo_loader.diff_cipher_tables().
154
+ If there was an exising table rename for comparison
128
155
 
129
- :param csv_path: Path like path of csv file.
156
+ :param existing: bool for table exists
157
+
158
+ :return: the old table name
130
159
  """
131
160
  old_table = f"{self.table_name}_old"
132
- had_existing = self.table_exists()
133
-
134
- # 1. Preserve existing table
135
- if had_existing:
161
+ self.conn.execute(f"DROP TABLE IF EXISTS {old_table}")
162
+ # Preserve existing table
163
+ if existing:
136
164
  self.conn.execute(f"ALTER TABLE {self.table_name} RENAME TO {old_table}")
165
+ return old_table
137
166
 
138
- # 2. Import CSV as new table
139
- mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
140
- self.row_class = self._build_dataclass_from_table()
141
-
142
- if not had_existing:
143
- return
167
+ def print_diff(self, old_table: str):
168
+ """
169
+ Print out diff between old and new db
144
170
 
171
+ :param old_table: The name the existing table was renamed to
172
+ """
145
173
  try:
146
- # 3. Diff old vs new (SQLCipher → sqlite3 → dataclasses)
174
+ # Diff old vs new (SQLCipher → sqlite3 → dataclasses)
147
175
  diff_rows = mojo_loader.diff_cipher_tables(
148
176
  self.conn,
149
177
  new_table=self.table_name,
@@ -156,14 +184,71 @@ class MojoSkel:
156
184
  print(diff.diff_type, diff.preview)
157
185
 
158
186
  finally:
159
- # 4. Cleanup old table (always)
187
+ # Cleanup old table (always)
160
188
  self.conn.execute(f"DROP TABLE {old_table}")
161
189
 
190
+ def download_csv(self, session: requests.Session, url: str, merge: bool = False):
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
+ :param merge: (optional) If True, merge into existing table. Defaults to False.
198
+ """
199
+ had_existing = False
200
+ old_table = ""
201
+
202
+ if not merge:
203
+ had_existing = self.table_exists()
204
+ old_table = self.rename_old_table(had_existing)
205
+
206
+ # Download CSV as new table
207
+ mojo_loader.download_csv_helper(
208
+ self.conn, self.table_name, url, session, merge=merge
209
+ )
210
+ self.row_class = self._build_dataclass_from_table()
211
+
212
+ if merge:
213
+ return
214
+
215
+ if not had_existing:
216
+ return
217
+
218
+ self.print_diff(old_table)
219
+
220
+ def import_csv(self, csv_path: Path, merge: bool = False):
221
+ """
222
+ Import the passed CSV into the encrypted sqlite database
223
+
224
+ :param csv_path: Path like path of csv file
225
+ :param merge: (optional) If True, merge into existing table. Defaults to False.
226
+ Form importing current, and expired members as headings are the same.
227
+ """
228
+ had_existing = False
229
+ old_table = ""
230
+
231
+ if not merge:
232
+ had_existing = self.table_exists()
233
+ old_table = self.rename_old_table(had_existing)
234
+
235
+ # Import CSV as new table
236
+ mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path, merge=merge)
237
+ self.row_class = self._build_dataclass_from_table()
238
+
239
+ if merge:
240
+ return
241
+
242
+ if not had_existing:
243
+ return
244
+
245
+ self.print_diff(old_table)
246
+
162
247
  def show_table(self, limit: int = 2):
163
248
  """
164
- Print the first few rows of the table as dictionaries.
249
+ Print the first few rows of the table as dictionaries
165
250
 
166
- :param limit: (optional) Number of rows to display. Defaults to 2.
251
+ :param limit: (optional) Number of rows to display. Defaults to 2
167
252
  """
168
253
  if self.table_exists():
169
254
  self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
@@ -178,7 +263,7 @@ class MojoSkel:
178
263
 
179
264
  def count(self) -> int:
180
265
  """
181
- :return: count of the number of rows in the table, or 0 if no table.
266
+ :return: count of the number of rows in the table, or 0 if no table
182
267
  """
183
268
  if self.table_exists():
184
269
  self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
@@ -187,33 +272,34 @@ class MojoSkel:
187
272
 
188
273
  return 0
189
274
 
190
- def get_row(self, entry_name: str, entry_value: str) -> dict:
275
+ def get_row(
276
+ self, entry_name: str, entry_value: str, only_one: bool = True
277
+ ) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
191
278
  """
192
- Retrieve a single row matching column = value (case-insensitive).
279
+ Retrieve a single or multiple rows matching column = value (case-insensitive)
193
280
 
194
- :param entry_name: Column name to filter by.
195
- :param entry_value: Value to match.
281
+ :param entry_name: Column name to filter by
282
+ :param entry_value: Value to match
283
+ :param only_one: If True (default), return the first matching row
284
+ If False, return a list of all matching rows
196
285
 
197
- :return: The matching row as a dictionary, or None if not found.
286
+ :return:
287
+ - If only_one=True → a single sqlite3.Row or None
288
+ - If only_one=False → list of sqlite3.Row (may be empty)
198
289
  """
199
- if not entry_value:
200
- return None
201
- query = (
202
- f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
203
- )
204
- self.cursor.execute(query, (entry_value,))
205
- row = self.cursor.fetchone()
206
- return dict(row) if row else None
290
+ match_dict = {f"{entry_name}": entry_value}
291
+
292
+ return self.get_row_multi(match_dict, only_one)
207
293
 
208
294
  def get_row_multi(
209
295
  self, match_dict: dict, only_one: bool = True
210
296
  ) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
211
297
  """
212
- Retrieve one or many rows matching multiple column=value pairs.
298
+ Retrieve one or many rows matching multiple column=value pairs
213
299
 
214
- :param match_dict: Dictionary of column names and values to match.
215
- :param only_one: If True (default), return the first matching row.
216
- If False, return a list of all matching rows.
300
+ :param match_dict: Dictionary of column names and values to match
301
+ :param only_one: If True (default), return the first matching row
302
+ If False, return a list of all matching rows
217
303
 
218
304
  :return:
219
305
  - If only_one=True → a single sqlite3.Row or None
@@ -225,6 +311,23 @@ class MojoSkel:
225
311
  for col, val in match_dict.items():
226
312
  if val is None or val == "":
227
313
  conditions.append(f'"{col}" IS NULL')
314
+ elif isinstance(val, Like):
315
+ conditions.append(f'LOWER("{col}") LIKE LOWER(?)')
316
+ values.append(val.pattern)
317
+ elif isinstance(val, (tuple, list)) and len(val) == 2:
318
+ lower, upper = val
319
+ if lower is not None and upper is not None:
320
+ conditions.append(f'"{col}" BETWEEN ? AND ?')
321
+ values.extend([lower, upper])
322
+ elif lower is not None:
323
+ conditions.append(f'"{col}" >= ?')
324
+ values.append(lower)
325
+ elif upper is not None:
326
+ conditions.append(f'"{col}" <= ?')
327
+ values.append(upper)
328
+ else:
329
+ # Both are None, effectively no condition on this column
330
+ pass
228
331
  else:
229
332
  conditions.append(f'"{col}" = ?')
230
333
  values.append(
@@ -233,25 +336,37 @@ class MojoSkel:
233
336
  else val
234
337
  )
235
338
 
339
+ # Base query string
236
340
  base_query = (
237
341
  f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
238
342
  )
343
+ if self.debug:
344
+ print("Sql:")
345
+ pprint(base_query)
239
346
 
240
347
  if only_one:
241
348
  query = base_query + " LIMIT 1"
242
349
  self.cursor.execute(query, values)
243
- return self.cursor.fetchone()
350
+ if row := self.cursor.fetchone():
351
+ return self._row_to_obj(row)
352
+ return None
244
353
 
245
- # Return *all* rows
246
354
  self.cursor.execute(base_query, values)
247
- return self.cursor.fetchall()
355
+ return [self._row_to_obj(row) for row in self.cursor.fetchall()]
356
+
357
+ def run_count_query(self, sql: str, params: tuple):
358
+ """
359
+ Generate whole sql query for running on db table for counting and run
360
+
361
+ :param sql: the sqlite query for matching rows
362
+ :param params: the paramaters to use for the query
363
+ """
364
+ sql_query = f"SELECT count (*) FROM {self.table_name} {sql}"
365
+ cursor = self.cursor.execute(sql_query, params)
366
+ return cursor.fetchone()[0]
248
367
 
249
368
  def table_exists(self) -> bool:
250
369
  """
251
- Return true or false if a table exists
370
+ Return True or False if a table exists
252
371
  """
253
- self.cursor.execute(
254
- "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
255
- (self.table_name,),
256
- )
257
- return self.cursor.fetchone() is not None
372
+ return mojo_loader.table_exists(self.cursor, self.table_name)