memberjojo 2.2__py3-none-any.whl → 2.3__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 = '2.3'
32
+ __version_tuple__ = version_tuple = (2, 3)
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]:
67
83
  """
68
- Iterate over table rows and yield dynamically-created dataclass objects.
69
- Converts REAL columns to Decimal automatically.
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
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,52 @@ 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):
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
+
162
228
  def show_table(self, limit: int = 2):
163
229
  """
164
- Print the first few rows of the table as dictionaries.
230
+ Print the first few rows of the table as dictionaries
165
231
 
166
- :param limit: (optional) Number of rows to display. Defaults to 2.
232
+ :param limit: (optional) Number of rows to display. Defaults to 2
167
233
  """
168
234
  if self.table_exists():
169
235
  self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
@@ -178,7 +244,7 @@ class MojoSkel:
178
244
 
179
245
  def count(self) -> int:
180
246
  """
181
- :return: count of the number of rows in the table, or 0 if no table.
247
+ :return: count of the number of rows in the table, or 0 if no table
182
248
  """
183
249
  if self.table_exists():
184
250
  self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
@@ -187,33 +253,34 @@ class MojoSkel:
187
253
 
188
254
  return 0
189
255
 
190
- def get_row(self, entry_name: str, entry_value: str) -> dict:
256
+ def get_row(
257
+ self, entry_name: str, entry_value: str, only_one: bool = True
258
+ ) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
191
259
  """
192
- Retrieve a single row matching column = value (case-insensitive).
260
+ Retrieve a single row matching column = value (case-insensitive)
193
261
 
194
- :param entry_name: Column name to filter by.
195
- :param entry_value: Value to match.
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
196
266
 
197
- :return: The matching row as a dictionary, or None if not found.
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)
198
270
  """
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
271
+ match_dict = {f"{entry_name}": entry_value}
272
+
273
+ return self.get_row_multi(match_dict, only_one)
207
274
 
208
275
  def get_row_multi(
209
276
  self, match_dict: dict, only_one: bool = True
210
277
  ) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
211
278
  """
212
- Retrieve one or many rows matching multiple column=value pairs.
279
+ Retrieve one or many rows matching multiple column=value pairs
213
280
 
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.
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
217
284
 
218
285
  :return:
219
286
  - If only_one=True → a single sqlite3.Row or None
@@ -225,6 +292,23 @@ class MojoSkel:
225
292
  for col, val in match_dict.items():
226
293
  if val is None or val == "":
227
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
228
312
  else:
229
313
  conditions.append(f'"{col}" = ?')
230
314
  values.append(
@@ -233,22 +317,27 @@ class MojoSkel:
233
317
  else val
234
318
  )
235
319
 
320
+ # Base query string
236
321
  base_query = (
237
322
  f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
238
323
  )
324
+ if self.debug:
325
+ print("Sql:")
326
+ pprint(base_query)
239
327
 
240
328
  if only_one:
241
329
  query = base_query + " LIMIT 1"
242
330
  self.cursor.execute(query, values)
243
- return self.cursor.fetchone()
331
+ if row := self.cursor.fetchone():
332
+ return self._row_to_obj(row)
333
+ return None
244
334
 
245
- # Return *all* rows
246
335
  self.cursor.execute(base_query, values)
247
- return self.cursor.fetchall()
336
+ return [self._row_to_obj(row) for row in self.cursor.fetchall()]
248
337
 
249
338
  def table_exists(self) -> bool:
250
339
  """
251
- Return true or false if a table exists
340
+ Return True or False if a table exists
252
341
  """
253
342
  self.cursor.execute(
254
343
  "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
memberjojo/mojo_loader.py CHANGED
@@ -1,22 +1,25 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Helper module for importing a CSV into a SQLite database.
3
+ Helper module for importing a CSV into a SQLite database
4
4
  """
5
5
 
6
6
  from collections import defaultdict, Counter
7
7
  from csv import DictReader
8
8
  from dataclasses import dataclass
9
+ from io import StringIO
9
10
  from pathlib import Path
10
- from typing import Any, Tuple
11
+ from typing import Any, IO, Tuple
11
12
 
12
13
  import re
13
14
  import sqlite3 as sqlite3_builtin
14
15
 
16
+ import requests
17
+
15
18
 
16
19
  @dataclass(frozen=True)
17
20
  class DiffRow:
18
21
  """
19
- Represents a single diff result.
22
+ Represents a single diff result
20
23
 
21
24
  - diff_type: 'added' | 'deleted' | 'changed'
22
25
  - preview: tuple of values, with preview[0] == key
@@ -33,11 +36,11 @@ class DiffRow:
33
36
 
34
37
  def _normalize(name: str) -> str:
35
38
  """
36
- Normalize a column name: lowercase, remove symbols, convert to snake case.
39
+ Normalize a column name: lowercase, remove symbols, convert to snake case
37
40
 
38
- :param name: Raw name to normalize.
41
+ :param name: Raw name to normalize
39
42
 
40
- :return: Normalized lowercase string in snake case with no symbols.
43
+ :return: Normalized lowercase string in snake case with no symbols
41
44
  """
42
45
  name = name.strip().lower()
43
46
  name = re.sub(r"[^a-z0-9]+", "_", name)
@@ -46,7 +49,7 @@ def _normalize(name: str) -> str:
46
49
 
47
50
  def _guess_type(value: any) -> str:
48
51
  """
49
- Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'.
52
+ Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'
50
53
 
51
54
  :param value: entry from sqlite database to guess the type of
52
55
 
@@ -71,8 +74,8 @@ def _guess_type(value: any) -> str:
71
74
 
72
75
  def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
73
76
  """
74
- Infer column types from CSV rows.
75
- Returns mapping: normalized column name -> SQLite type.
77
+ Infer column types from CSV rows
78
+ Returns mapping: normalized column name -> SQLite type
76
79
 
77
80
  :param rows: list of rows to use for inference
78
81
 
@@ -104,22 +107,18 @@ def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
104
107
 
105
108
  def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
106
109
  """
107
- Generate CREATE TABLE SQL from column type mapping.
110
+ Generate CREATE TABLE SQL from column type mapping
111
+ Adds an auto-incrementing rowid as the primary key
108
112
 
109
- :param table_name: Table to use when creating columns.
110
- :param columns: dict of columns to create.
113
+ :param table_name: Table to use when creating columns
114
+ :param columns: dict of columns to create
111
115
 
112
- :return: SQL commands to create the table.
116
+ :return: SQL commands to create the table
113
117
  """
114
- col_defs = []
115
- first = True
118
+ col_defs = ["rowid INTEGER PRIMARY KEY AUTOINCREMENT"]
116
119
 
117
120
  for col, col_type in columns.items():
118
- if first:
119
- col_defs.append(f'"{col}" {col_type} PRIMARY KEY')
120
- first = False
121
- else:
122
- col_defs.append(f'"{col}" {col_type}')
121
+ col_defs.append(f'"{col}" {col_type}')
123
122
 
124
123
  return (
125
124
  f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n' + ",\n".join(col_defs) + "\n)"
@@ -131,14 +130,47 @@ def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
131
130
  # -----------------------
132
131
 
133
132
 
133
+ def import_data(conn, table_name: str, reader: DictReader):
134
+ """
135
+ Import data in the DictReader into the SQLite3 database at conn
136
+
137
+ :param conn: SQLite database connection to use
138
+ :param table_name: Name of the table to import into
139
+ :param reader: A Dictreader object to import from
140
+ """
141
+ inferred_cols = infer_columns_from_rows(reader)
142
+
143
+ cursor = conn.cursor()
144
+ # Drop existing table
145
+ cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
146
+
147
+ # Create table
148
+ create_sql = _create_table_from_columns(table_name, inferred_cols)
149
+ cursor.execute(create_sql)
150
+
151
+ # Insert rows
152
+ cols = list(reader[0].keys())
153
+ norm_map = {c: _normalize(c) for c in cols}
154
+ colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
155
+ placeholders = ",".join("?" for _ in cols)
156
+ insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
157
+
158
+ for row in reader:
159
+ values = [row[c] if row[c] != "" else None for c in cols]
160
+ cursor.execute(insert_sql, values)
161
+
162
+ cursor.close()
163
+ conn.commit()
164
+
165
+
134
166
  def import_csv_helper(conn, table_name: str, csv_path: Path):
135
167
  """
136
- Import CSV into database using given cursor.
137
- Column types inferred automatically.
168
+ Import CSV into database using given cursor
169
+ Column types inferred automatically
138
170
 
139
- :param conn: SQLite database connection to use.
140
- :param table_name: Table to import the CSV into.
141
- :param csv_path: Path like path of the CSV file to import.
171
+ :param conn: SQLite database connection to use
172
+ :param table_name: Table to import the CSV into
173
+ :param csv_path: Path like path of the CSV file to import
142
174
  """
143
175
  if not csv_path.exists():
144
176
  raise FileNotFoundError(f"CSV file not found: {csv_path}")
@@ -148,29 +180,7 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
148
180
  reader = list(DictReader(f))
149
181
  if not reader:
150
182
  raise ValueError("CSV file is empty.")
151
- inferred_cols = infer_columns_from_rows(reader)
152
-
153
- cursor = conn.cursor()
154
- # Drop existing table
155
- cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
156
-
157
- # Create table
158
- create_sql = _create_table_from_columns(table_name, inferred_cols)
159
- cursor.execute(create_sql)
160
-
161
- # Insert rows
162
- cols = list(reader[0].keys())
163
- norm_map = {c: _normalize(c) for c in cols}
164
- colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
165
- placeholders = ",".join("?" for _ in cols)
166
- insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
167
-
168
- for row in reader:
169
- values = [row[c] if row[c] != "" else None for c in cols]
170
- cursor.execute(insert_sql, values)
171
-
172
- cursor.close()
173
- conn.commit()
183
+ import_data(conn, table_name, reader)
174
184
 
175
185
 
176
186
  # -----------------------
@@ -180,7 +190,7 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
180
190
 
181
191
  def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
182
192
  """
183
- Convert a sqlite3.Row from generate_sql_diff into DiffRow.
193
+ Convert a sqlite3.Row from generate_sql_diff into DiffRow
184
194
  Row shape:
185
195
  (diff_type, col1, col2, col3, ...)
186
196
 
@@ -195,7 +205,7 @@ def _diffrow_from_sql_row(row: sqlite3_builtin.Row) -> DiffRow:
195
205
 
196
206
 
197
207
  def diff_cipher_tables(
198
- cipher_conn,
208
+ conn,
199
209
  *,
200
210
  new_table: str,
201
211
  old_table: str,
@@ -204,7 +214,7 @@ def diff_cipher_tables(
204
214
  Copy old and new tables from SQLCipher into a single
205
215
  in-memory sqlite3 database and diff them there.
206
216
 
207
- :param cipher_conn: sqlite connection to the encrypted db
217
+ :param conn: sqlite connection to the db
208
218
  :param new_table: name of the new table for comparison
209
219
  :param old_table: name of the old table for comparison
210
220
 
@@ -216,8 +226,8 @@ def diff_cipher_tables(
216
226
 
217
227
  try:
218
228
  for table in (old_table, new_table):
219
- # 1. Clone schema using SQLite itself
220
- schema_sql = cipher_conn.execute(
229
+ # Clone schema using SQLite itself
230
+ schema_sql = conn.execute(
221
231
  """
222
232
  SELECT sql
223
233
  FROM sqlite_master
@@ -232,7 +242,7 @@ def diff_cipher_tables(
232
242
  plain.execute(schema_sql[0])
233
243
 
234
244
  # 2. Copy data
235
- rows = cipher_conn.execute(f"SELECT * FROM {table}")
245
+ rows = conn.execute(f"SELECT * FROM {table}")
236
246
  cols = [d[0] for d in rows.description]
237
247
 
238
248
  col_list = ", ".join(cols)
@@ -263,9 +273,9 @@ def _generate_sql_diff(
263
273
  old_table: str,
264
274
  ) -> list[sqlite3_builtin.Row]:
265
275
  """
266
- Generate a diff between two tables using standard SQLite features.
276
+ Generate a diff between two tables using standard SQLite features
267
277
 
268
- - The FIRST column is the primary key.
278
+ - Uses rowid as the primary key for joining
269
279
  - Returned row shape:
270
280
  (diff_type, preview_col1, preview_col2, preview_col3, ...)
271
281
 
@@ -276,7 +286,7 @@ def _generate_sql_diff(
276
286
  :return: list of sqlite rows that are changed
277
287
  """
278
288
 
279
- # 1. Introspect schema (order-preserving)
289
+ # Introspect schema (order-preserving)
280
290
  cols_info = conn.execute(f"PRAGMA table_info({new_table})").fetchall()
281
291
 
282
292
  if not cols_info:
@@ -284,16 +294,20 @@ def _generate_sql_diff(
284
294
 
285
295
  cols = [row[1] for row in cols_info]
286
296
 
287
- key = cols[0]
288
- non_key_cols = cols[1:]
297
+ # Exclude rowid from consideration for key or data
298
+ main_cols = [c for c in cols if c != "rowid"]
289
299
 
290
- # 2. Preview columns (key first, limit for readability)
300
+ # First column is key, others are for comparison
301
+ key = main_cols[0]
302
+ non_key_cols = main_cols[1:]
303
+
304
+ # Preview columns (key first, then others for readability)
291
305
  preview_cols = [key] + non_key_cols[:5]
292
306
 
293
307
  new_preview = ", ".join(f"n.{c}" for c in preview_cols)
294
308
  old_preview = ", ".join(f"o.{c}" for c in preview_cols)
295
309
 
296
- # 3. Row-value comparison (NULL-safe)
310
+ # Row-value comparison (NULL-safe)
297
311
  if non_key_cols:
298
312
  changed_predicate = (
299
313
  f"({', '.join(f'n.{c}' for c in non_key_cols)}) "
@@ -333,3 +347,38 @@ def _generate_sql_diff(
333
347
  """
334
348
 
335
349
  return list(conn.execute(sql))
350
+
351
+
352
+ def download_csv_helper(
353
+ conn, table_name: str, url: str, session: requests.Session
354
+ ) -> IO[str]:
355
+ """
356
+ Download url into a StringIO file object using streaming
357
+ and import into database
358
+
359
+ :param conn: The SQLite3 database connection to use
360
+ :param table_name: The name of the table to import it into
361
+ :param url: URL of the csv to download
362
+ :param session: A requests session to use for the download
363
+ """
364
+
365
+ print(f"Downloading from: {url}")
366
+
367
+ # Enable streaming
368
+ with session.get(url, stream=True) as resp:
369
+ resp.raise_for_status()
370
+
371
+ # Initialize the string buffer
372
+ string_buffer = StringIO()
373
+
374
+ # Stream decoded text
375
+ # decode_unicode=True uses the encoding from the response headers
376
+ for chunk in resp.iter_content(chunk_size=8192, decode_unicode=True):
377
+ if chunk:
378
+ string_buffer.write(chunk)
379
+
380
+ # Reset pointer to the beginning for DictReader
381
+ string_buffer.seek(0)
382
+
383
+ print(f"✅ Downloaded with encoding {resp.encoding}.")
384
+ import_data(conn, table_name, list(DictReader(string_buffer)))
memberjojo/mojo_member.py CHANGED
@@ -1,25 +1,28 @@
1
1
  """
2
- Member module for creating and interacting with a SQLite database.
2
+ Member module for creating and interacting with a SQLite database
3
3
 
4
4
  This module loads data from a `members.csv` file downloaded from Membermojo,
5
- stores it in SQLite, and provides helper functions for member lookups.
5
+ stores it in SQLite, and provides helper functions for member lookups
6
6
  """
7
7
 
8
+ from difflib import get_close_matches
8
9
  from pathlib import Path
10
+ from pprint import pprint
9
11
  from typing import Optional
10
12
  from .mojo_common import MojoSkel
11
13
 
12
14
 
13
15
  class Member(MojoSkel):
14
16
  """
15
- Subclass of MojoSkel providing member-specific database functions.
17
+ Subclass of MojoSkel providing member-specific database functions
16
18
 
17
19
  This class connects to a SQLite database and supports importing member data
18
- from CSV and performing queries like lookup by name or member number.
20
+ from CSV and performing queries like lookup by name or member number
19
21
 
20
- :param member_db_path (Path): Path to the SQLite database file.
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.
22
+ :param member_db_path (Path): Path to the SQLite database file
23
+ :param db_key: key to unlock the encrypted sqlite database,
24
+ unencrypted if sqlcipher3 not installed or unset
25
+ :param table_name (str): (optional) Table name to use. Defaults to "members"
23
26
  """
24
27
 
25
28
  def __init__(
@@ -29,23 +32,50 @@ class Member(MojoSkel):
29
32
  table_name: str = "members",
30
33
  ):
31
34
  """
32
- Initialize the Member database handler.
35
+ Initialize the Member database handler
33
36
  """
34
37
  super().__init__(member_db_path, db_key, table_name)
35
38
 
39
+ def get_bool(self, entry_name: str, member_number: int) -> bool:
40
+ """
41
+ Return a bool for a member entry that is a tick box on membermojo
42
+
43
+ :param entry_name: The entry name to return as a bool
44
+ :param member_number: The member number to check value of entry_name
45
+
46
+ :return: True is entry is yes otherwise False
47
+
48
+ :raises ValueError: If entry name not found
49
+ """
50
+ sql = f"""
51
+ SELECT "{entry_name}"
52
+ FROM "{self.table_name}"
53
+ WHERE "member_number" = ?
54
+ """
55
+ self.cursor.execute(sql, (member_number,))
56
+
57
+ row = self.cursor.fetchone()
58
+ if row is None:
59
+ raise ValueError(
60
+ f"❌ Cannot find: {entry_name} for member {member_number}."
61
+ )
62
+
63
+ value = row[0]
64
+ return str(value).lower() == "yes"
65
+
36
66
  def get_number_first_last(
37
67
  self, first_name: str, last_name: str, found_error: bool = False
38
68
  ) -> Optional[int]:
39
69
  """
40
- Find a member number based on first and last name (case-insensitive).
70
+ Find a member number based on first and last name (case-insensitive)
41
71
 
42
- :param first_name: First name of the member.
43
- :param last_name: Last name of the member.
44
- :param found_error: (optional): If True, raises ValueError if not found.
72
+ :param first_name: First name of the member
73
+ :param last_name: Last name of the member
74
+ :param found_error: (optional): If True, raises ValueError if not found
45
75
 
46
- :return: The member number if found, otherwise None.
76
+ :return: The member number if found, otherwise None
47
77
 
48
- :raises ValueError: If not found and `found_error` is True.
78
+ :raises ValueError: If not found and `found_error` is True
49
79
  """
50
80
  sql = f"""
51
81
  SELECT "member_number"
@@ -54,6 +84,12 @@ class Member(MojoSkel):
54
84
  """
55
85
  self.cursor.execute(sql, (first_name, last_name))
56
86
  result = self.cursor.fetchone()
87
+ if self.debug:
88
+ print("Sql:")
89
+ pprint(sql)
90
+ print(f"First Name: {first_name} Last Name: {last_name}")
91
+ print("Result:")
92
+ pprint(result[0])
57
93
 
58
94
  if not result and found_error:
59
95
  raise ValueError(
@@ -64,18 +100,22 @@ class Member(MojoSkel):
64
100
 
65
101
  def get_number(self, full_name: str, found_error: bool = False) -> Optional[int]:
66
102
  """
67
- Find a member number by passed full_name.
103
+ Find a member number by passed full_name
68
104
  Tries first and last, and then middle last if 3 words,
69
- Then initial of first name if initials passed.
105
+ Then initial of first name if initials passed
106
+ Finnaly a fuzzy lookup is tried
70
107
 
71
- :param full_name: Full name of the member.
72
- :param found_error: (optional) Raise ValueError if not found.
108
+ :param full_name: Full name of the member
109
+ :param found_error: (optional) Raise ValueError if not found
73
110
 
74
- :return: Member number if found, else None.
111
+ :return: Member number if found, else None
75
112
 
76
- :raises ValueError: If not found and `found_error` is True.
113
+ :raises ValueError: If not found and `found_error` is True
77
114
  """
78
- result = self.get_mojo_name(full_name, found_error)
115
+ result = self.get_mojo_name(full_name)
116
+ if not result:
117
+ result = self.get_fuzz_name(full_name, found_error)
118
+
79
119
  if result:
80
120
  return self.get_number_first_last(result[0], result[1])
81
121
  return None
@@ -123,7 +163,7 @@ class Member(MojoSkel):
123
163
  self, full_name: str, found_error: bool = False
124
164
  ) -> Optional[tuple]:
125
165
  """
126
- Resolve a member name from a free-text full name.
166
+ Resolve a member name from a free-text full name
127
167
 
128
168
  **Search order**
129
169
 
@@ -132,14 +172,14 @@ class Member(MojoSkel):
132
172
  3. initial 1st letter + last
133
173
  4. initial 2nd letter + last (for two-letter initials)
134
174
 
135
- Returns (first_name, last_name) or None.
175
+ Returns (first_name, last_name) or None
136
176
 
137
- :param full_name: Full name of the member to find.
138
- :param found_error: (optional) Raise ValueError if not found.
177
+ :param full_name: Full name of the member to find
178
+ :param found_error: (optional) Raise ValueError if not found
139
179
 
140
- :return: Membermojo name if found, else None.
180
+ :return: Membermojo name if found, else None
141
181
 
142
- :raises ValueError: If not found and `found_error` is True.
182
+ :raises ValueError: If not found and `found_error` is True
143
183
  """
144
184
 
145
185
  parts = full_name.strip().split()
@@ -207,11 +247,11 @@ class Member(MojoSkel):
207
247
 
208
248
  def get_first_last_name(self, member_number: int) -> Optional[str]:
209
249
  """
210
- Get full name for a given member number.
250
+ Get full name for a given member number
211
251
 
212
- :param member_number: Member number to look up.
252
+ :param member_number: Member number to look up
213
253
 
214
- :return: Full name as tuple, or None if not found.
254
+ :return: Full name as tuple, or None if not found
215
255
  """
216
256
  sql = f"""
217
257
  SELECT "first_name", "last_name"
@@ -225,11 +265,11 @@ class Member(MojoSkel):
225
265
 
226
266
  def get_name(self, member_number: int) -> Optional[str]:
227
267
  """
228
- Get full name for a given member number.
268
+ Get full name for a given member number
229
269
 
230
- :param member_number: Member number to look up.
270
+ :param member_number: Member number to look up
231
271
 
232
- :return: Full name as "First Last", or None if not found.
272
+ :return: Full name as "First Last", or None if not found
233
273
  """
234
274
 
235
275
  result = self.get_first_last_name(member_number)
@@ -238,3 +278,39 @@ class Member(MojoSkel):
238
278
  first_name, last_name = result
239
279
  return f"{first_name} {last_name}"
240
280
  return None
281
+
282
+ def get_fuzz_name(self, name: str, found_error: bool = False):
283
+ """
284
+ Fuzzy search for members by name using partial matching
285
+ Searches across first_name and last_name fields
286
+
287
+ :param name: Free text name to search for (partial match)
288
+
289
+
290
+ :return: Tuple of (first_name, last_name) or None
291
+
292
+ :raises ValueError: If not found and `found_error` is True
293
+ """
294
+
295
+ name = name.strip().lower()
296
+
297
+ # Get all members
298
+ self.cursor.execute(
299
+ f'SELECT *, LOWER(first_name || " " || last_name) AS full FROM "{self.table_name}"'
300
+ )
301
+ rows = self.cursor.fetchall()
302
+
303
+ choices = [row["full"] for row in rows]
304
+ matches = get_close_matches(name, choices, n=1, cutoff=0.7)
305
+
306
+ if not matches:
307
+ if found_error:
308
+ raise ValueError(
309
+ f"❌ Cannot find {name} in member database with fuzzy match."
310
+ )
311
+ return None
312
+
313
+ match = matches[0]
314
+ # return the sqlite row for the best match
315
+ row = next(r for r in rows if r["full"] == match)
316
+ return (row["first_name"], row["last_name"])
@@ -1,8 +1,8 @@
1
1
  """
2
- Module to import and interact with Membermojo completed_payments.csv data in SQLite.
2
+ Module to import and interact with Membermojo completed_payments.csv data in SQLite
3
3
 
4
4
  Provides automatic column type inference, robust CSV importing, and
5
- helper methods for querying the database.
5
+ helper methods for querying the database
6
6
  """
7
7
 
8
8
  from .mojo_common import MojoSkel
@@ -10,14 +10,15 @@ from .mojo_common import MojoSkel
10
10
 
11
11
  class Transaction(MojoSkel):
12
12
  """
13
- Handles importing and querying completed payment data.
13
+ Handles importing and querying completed payment data
14
14
 
15
15
  Extends:
16
- MojoSkel: Base class with transaction database operations.
16
+ MojoSkel: Base class with transaction database operations
17
17
 
18
- :param payment_db_path: Path to the SQLite database.
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.
18
+ :param payment_db_path: Path to the SQLite database
19
+ :param db_key: key to unlock the encrypted sqlite database,
20
+ unencrypted if sqlcipher3 not installed or unset
21
+ :param table_name: (optional) Name of the table. Defaults to "payments"
21
22
  """
22
23
 
23
24
  def __init__(
@@ -27,6 +28,6 @@ class Transaction(MojoSkel):
27
28
  table_name: str = "payments",
28
29
  ):
29
30
  """
30
- Initialize the Transaction object.
31
+ Initialize the Transaction object
31
32
  """
32
33
  super().__init__(payment_db_path, db_key, table_name)
@@ -0,0 +1,12 @@
1
+ """
2
+ Classes for use in sqlite row matching
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Like:
10
+ """Marker type for SQL LIKE comparisons"""
11
+
12
+ pattern: str
memberjojo/url.py ADDED
@@ -0,0 +1,82 @@
1
+ """
2
+ A class for managing Membermojo URLs
3
+ """
4
+
5
+
6
+ class URL:
7
+ """A class for managing Membermojo URLs"""
8
+
9
+ def __init__(self, shortname: str):
10
+ """
11
+ Ininitalise the class
12
+
13
+ :param shortname: the shortname setting on membermojo
14
+ """
15
+ self.shortname = shortname
16
+ self.base_url = "https://membermojo.co.uk"
17
+
18
+ def make_url(self, endpoint: str) -> str:
19
+ """
20
+ return a whole url for endpoint
21
+
22
+ :param endpoint: the endpoint to make url for
23
+
24
+ :return: a complete url
25
+ """
26
+ return f"{self.base_url}/{self.shortname}/{endpoint}"
27
+
28
+ def members(self, state: str = "") -> str:
29
+ """
30
+ return the active, expired, or archived urls
31
+
32
+ :param state: membership state to return
33
+
34
+ :return: url for the state
35
+
36
+ state:
37
+ "active" or "" -> active members
38
+ "expired" -> expired members
39
+ "archived" -> archived members
40
+ """
41
+ if state not in {"", "expired", "archived"}:
42
+ raise ValueError(f"Invalid member state: {state}")
43
+
44
+ if state == "active":
45
+ state = ""
46
+ suffix = f"_{state}" if state else ""
47
+ return f"{self.membership}/download{suffix}_members"
48
+
49
+ @property
50
+ def login(self):
51
+ """Returns the membermojo login URL"""
52
+ return self.make_url("signin_password")
53
+
54
+ @property
55
+ def membership(self):
56
+ """Returns the URL for membership"""
57
+ return self.make_url("membership")
58
+
59
+ @property
60
+ def completed_payments(self):
61
+ """Returns the completed payments download URL"""
62
+ return f"{self.membership}/download_completed_payments?state=CO"
63
+
64
+ @property
65
+ def pending_aproval(self):
66
+ """Returns the members pending approval URL"""
67
+ return f"{self.membership}/download_pending_approval_members"
68
+
69
+ @property
70
+ def pending_completion(self):
71
+ """Returns the members pending completion URL"""
72
+ return f"{self.membership}/download_pending_completion_members"
73
+
74
+ @property
75
+ def pending_payments(self):
76
+ """Returns the members pending payments URL"""
77
+ return f"{self.membership}/download_pending_payments"
78
+
79
+ @property
80
+ def test(self):
81
+ """Returns the test URL for login verification"""
82
+ return self.membership
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memberjojo
3
- Version: 2.2
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,28 +16,34 @@ 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
- Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
46
+ Installing via `pip` on macos with optional `sqlcipher` installed via homebrew:\
42
47
  (The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` and
43
48
  `LIBRARY_PATH` are needed for the `libsqlcipher` files to be found)
44
49
 
@@ -46,13 +51,19 @@ Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
46
51
  brew install sqlcipher
47
52
  export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
48
53
  export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
49
- pip install memberjojo
54
+ pip install memberjojo[sqlciper]
50
55
  ```
51
56
 
52
57
  Installing via `pip` on ubuntu:
53
58
 
54
59
  ```bash
55
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
56
67
  pip install memberjojo
57
68
  ```
58
69
 
@@ -0,0 +1,13 @@
1
+ memberjojo/__init__.py,sha256=kO0EB2VTfHQFyqERL0qKRlYzAkD_cTm-b3q3uT1r91g,343
2
+ memberjojo/_version.py,sha256=2i05UdhZ8zjlTikPn5gGyFdwY-FJwBWYSRHEBlHO5KU,699
3
+ memberjojo/download.py,sha256=N8s5kFm58gbOaPyWvXxRigbMk5J6vjOx4_GX--JtWFY,4387
4
+ memberjojo/mojo_common.py,sha256=lE9LVVMGI5mbw5n6ZItvqSdswb4sJfLdwfRsgEocGuA,11415
5
+ memberjojo/mojo_loader.py,sha256=QOEPqVrswI82xqkj9CbtGmjRX9sqpRWZBSyhEnm82Os,10758
6
+ memberjojo/mojo_member.py,sha256=mr-4C4W_swzxQ1PcNi0884k59eRGo8fABAEQ0WVjyRo,10472
7
+ memberjojo/mojo_transaction.py,sha256=KCbhrY1Wccs_GoXHtuXgohn5MdxnUiMpKdhcVE348KU,933
8
+ memberjojo/sql_query.py,sha256=4T6EVYZo7q0VQ2Ah6uuX20Ub_g0RsBoKs1qrZmqRK7w,185
9
+ memberjojo/url.py,sha256=vx-l1FlrohlpAExPICU5UYRl34z_tYXvWLzGAaprdXI,2320
10
+ memberjojo-2.3.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
11
+ memberjojo-2.3.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
12
+ memberjojo-2.3.dist-info/METADATA,sha256=_1PYJvM6M4f82d4WB-f0tOy7TghxL86HbmURa8RWT3U,3964
13
+ memberjojo-2.3.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- memberjojo/__init__.py,sha256=JGhIJ1HGbzrwzF4HsopOdP4eweLjUmFjbIDvMbk1MKI,291
2
- memberjojo/_version.py,sha256=_Nk5-Ctb0Jdj1fjUXI6x5dxb-FwdE56jfaBDPomGv_A,699
3
- memberjojo/mojo_common.py,sha256=DqJ3NKq1Lrcv8NejJFR3ZHlwZifTjqIZv6Vp0R8yOQQ,8340
4
- memberjojo/mojo_loader.py,sha256=pKqp7QiirThYd6gIy1LQ48GbV2DVFL0yZSLUWxuTLa0,9232
5
- memberjojo/mojo_member.py,sha256=3UeBIVtSXBCUpvuGN9fIJ0zgXu6vXhs4wFDHeDnOG0o,8078
6
- memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
7
- memberjojo-2.2.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
8
- memberjojo-2.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
9
- memberjojo-2.2.dist-info/METADATA,sha256=p8d0fTLtfNk5Hir3CRwjBl5WhSYrZL4UpiokpsE0Wsk,3593
10
- memberjojo-2.2.dist-info/RECORD,,