memberjojo 2.3__tar.gz → 3.0__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.
- {memberjojo-2.3 → memberjojo-3.0}/PKG-INFO +1 -1
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/_version.py +3 -3
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/mojo_common.py +40 -14
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/mojo_loader.py +32 -13
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/mojo_member.py +13 -0
- memberjojo-3.0/tests/__init__.py +0 -0
- memberjojo-3.0/tests/test_merge_import.py +76 -0
- {memberjojo-2.3 → memberjojo-3.0}/tests/test_mojo_import_diff.py +1 -10
- {memberjojo-2.3 → memberjojo-3.0}/tests/test_mojo_members.py +14 -0
- memberjojo-3.0/tests/utils.py +13 -0
- {memberjojo-2.3 → memberjojo-3.0}/LICENSE +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/README.md +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/pyproject.toml +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/__init__.py +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/download.py +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/mojo_transaction.py +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/sql_query.py +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/src/memberjojo/url.py +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/tests/test_mojo_members_iter.py +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/tests/test_mojo_transactions.py +0 -0
- {memberjojo-2.3 → memberjojo-3.0}/tests/test_version.py +0 -0
|
@@ -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 = '
|
|
32
|
-
__version_tuple__ = version_tuple = (
|
|
31
|
+
__version__ = version = '3.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (3, 0)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g594a20473'
|
|
@@ -187,39 +187,58 @@ class MojoSkel:
|
|
|
187
187
|
# Cleanup old table (always)
|
|
188
188
|
self.conn.execute(f"DROP TABLE {old_table}")
|
|
189
189
|
|
|
190
|
-
def download_csv(self, session: requests.Session, url: str):
|
|
190
|
+
def download_csv(self, session: requests.Session, url: str, merge: bool = False):
|
|
191
191
|
"""
|
|
192
192
|
Download the CSV from url and import into the sqlite database
|
|
193
193
|
If a previous table exists, generate a diff
|
|
194
194
|
|
|
195
195
|
:param session: Requests session to use for download
|
|
196
196
|
:param url: url of the csv to download
|
|
197
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
197
198
|
"""
|
|
198
|
-
had_existing =
|
|
199
|
-
old_table =
|
|
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)
|
|
200
205
|
|
|
201
206
|
# Download CSV as new table
|
|
202
|
-
mojo_loader.download_csv_helper(
|
|
207
|
+
mojo_loader.download_csv_helper(
|
|
208
|
+
self.conn, self.table_name, url, session, merge=merge
|
|
209
|
+
)
|
|
203
210
|
self.row_class = self._build_dataclass_from_table()
|
|
204
211
|
|
|
212
|
+
if merge:
|
|
213
|
+
return
|
|
214
|
+
|
|
205
215
|
if not had_existing:
|
|
206
216
|
return
|
|
207
217
|
|
|
208
218
|
self.print_diff(old_table)
|
|
209
219
|
|
|
210
|
-
def import_csv(self, csv_path: Path):
|
|
220
|
+
def import_csv(self, csv_path: Path, merge: bool = False):
|
|
211
221
|
"""
|
|
212
222
|
Import the passed CSV into the encrypted sqlite database
|
|
213
223
|
|
|
214
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.
|
|
215
227
|
"""
|
|
216
|
-
had_existing =
|
|
217
|
-
old_table =
|
|
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)
|
|
218
234
|
|
|
219
235
|
# Import CSV as new table
|
|
220
|
-
mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
|
|
236
|
+
mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path, merge=merge)
|
|
221
237
|
self.row_class = self._build_dataclass_from_table()
|
|
222
238
|
|
|
239
|
+
if merge:
|
|
240
|
+
return
|
|
241
|
+
|
|
223
242
|
if not had_existing:
|
|
224
243
|
return
|
|
225
244
|
|
|
@@ -257,7 +276,7 @@ class MojoSkel:
|
|
|
257
276
|
self, entry_name: str, entry_value: str, only_one: bool = True
|
|
258
277
|
) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
|
|
259
278
|
"""
|
|
260
|
-
Retrieve a single
|
|
279
|
+
Retrieve a single or multiple rows matching column = value (case-insensitive)
|
|
261
280
|
|
|
262
281
|
:param entry_name: Column name to filter by
|
|
263
282
|
:param entry_value: Value to match
|
|
@@ -335,12 +354,19 @@ class MojoSkel:
|
|
|
335
354
|
self.cursor.execute(base_query, values)
|
|
336
355
|
return [self._row_to_obj(row) for row in self.cursor.fetchall()]
|
|
337
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]
|
|
367
|
+
|
|
338
368
|
def table_exists(self) -> bool:
|
|
339
369
|
"""
|
|
340
370
|
Return True or False if a table exists
|
|
341
371
|
"""
|
|
342
|
-
self.cursor.
|
|
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
|
|
372
|
+
return mojo_loader.table_exists(self.cursor, self.table_name)
|
|
@@ -125,28 +125,45 @@ def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
|
125
125
|
)
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
def table_exists(cursor, table_name: str) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Return True or False if a table exists
|
|
131
|
+
|
|
132
|
+
:param cursor: SQLite cursor of db to find table in
|
|
133
|
+
:param table_name: name of the table to check existance of
|
|
134
|
+
|
|
135
|
+
:return: bool of existence
|
|
136
|
+
"""
|
|
137
|
+
cursor.execute(
|
|
138
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
|
|
139
|
+
(table_name,),
|
|
140
|
+
)
|
|
141
|
+
return cursor.fetchone() is not None
|
|
142
|
+
|
|
143
|
+
|
|
128
144
|
# -----------------------
|
|
129
145
|
# CSV Import
|
|
130
146
|
# -----------------------
|
|
131
147
|
|
|
132
148
|
|
|
133
|
-
def import_data(conn, table_name: str, reader: DictReader):
|
|
149
|
+
def import_data(conn, table_name: str, reader: DictReader, merge: bool = False):
|
|
134
150
|
"""
|
|
135
151
|
Import data in the DictReader into the SQLite3 database at conn
|
|
136
152
|
|
|
137
153
|
:param conn: SQLite database connection to use
|
|
138
154
|
:param table_name: Name of the table to import into
|
|
139
155
|
:param reader: A Dictreader object to import from
|
|
156
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
140
157
|
"""
|
|
141
|
-
inferred_cols = infer_columns_from_rows(reader)
|
|
142
158
|
|
|
143
159
|
cursor = conn.cursor()
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
160
|
+
if not merge or not table_exists(cursor, table_name):
|
|
161
|
+
# Drop existing table
|
|
162
|
+
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
163
|
+
inferred_cols = infer_columns_from_rows(reader)
|
|
164
|
+
# Create table
|
|
165
|
+
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
166
|
+
cursor.execute(create_sql)
|
|
150
167
|
|
|
151
168
|
# Insert rows
|
|
152
169
|
cols = list(reader[0].keys())
|
|
@@ -163,7 +180,7 @@ def import_data(conn, table_name: str, reader: DictReader):
|
|
|
163
180
|
conn.commit()
|
|
164
181
|
|
|
165
182
|
|
|
166
|
-
def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
183
|
+
def import_csv_helper(conn, table_name: str, csv_path: Path, merge: bool = False):
|
|
167
184
|
"""
|
|
168
185
|
Import CSV into database using given cursor
|
|
169
186
|
Column types inferred automatically
|
|
@@ -171,6 +188,7 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
|
171
188
|
:param conn: SQLite database connection to use
|
|
172
189
|
:param table_name: Table to import the CSV into
|
|
173
190
|
:param csv_path: Path like path of the CSV file to import
|
|
191
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
174
192
|
"""
|
|
175
193
|
if not csv_path.exists():
|
|
176
194
|
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
@@ -180,7 +198,7 @@ def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
|
180
198
|
reader = list(DictReader(f))
|
|
181
199
|
if not reader:
|
|
182
200
|
raise ValueError("CSV file is empty.")
|
|
183
|
-
import_data(conn, table_name, reader)
|
|
201
|
+
import_data(conn, table_name, reader, merge=merge)
|
|
184
202
|
|
|
185
203
|
|
|
186
204
|
# -----------------------
|
|
@@ -350,7 +368,7 @@ def _generate_sql_diff(
|
|
|
350
368
|
|
|
351
369
|
|
|
352
370
|
def download_csv_helper(
|
|
353
|
-
conn, table_name: str, url: str, session: requests.Session
|
|
371
|
+
conn, table_name: str, url: str, session: requests.Session, merge: bool = False
|
|
354
372
|
) -> IO[str]:
|
|
355
373
|
"""
|
|
356
374
|
Download url into a StringIO file object using streaming
|
|
@@ -360,9 +378,10 @@ def download_csv_helper(
|
|
|
360
378
|
:param table_name: The name of the table to import it into
|
|
361
379
|
:param url: URL of the csv to download
|
|
362
380
|
:param session: A requests session to use for the download
|
|
381
|
+
:param merge: (optional) If True, merge into existing table. Defaults to False.
|
|
363
382
|
"""
|
|
364
383
|
|
|
365
|
-
print(f"Downloading from: {url}")
|
|
384
|
+
print(f"☁️ Downloading from: {url}")
|
|
366
385
|
|
|
367
386
|
# Enable streaming
|
|
368
387
|
with session.get(url, stream=True) as resp:
|
|
@@ -381,4 +400,4 @@ def download_csv_helper(
|
|
|
381
400
|
string_buffer.seek(0)
|
|
382
401
|
|
|
383
402
|
print(f"✅ Downloaded with encoding {resp.encoding}.")
|
|
384
|
-
import_data(conn, table_name, list(DictReader(string_buffer)))
|
|
403
|
+
import_data(conn, table_name, list(DictReader(string_buffer)), merge=merge)
|
|
@@ -63,6 +63,19 @@ class Member(MojoSkel):
|
|
|
63
63
|
value = row[0]
|
|
64
64
|
return str(value).lower() == "yes"
|
|
65
65
|
|
|
66
|
+
def member_type_count(self, membership_type: str):
|
|
67
|
+
"""
|
|
68
|
+
Count members by membership type string
|
|
69
|
+
|
|
70
|
+
:param membership_type: the string to match, can use percent to match
|
|
71
|
+
remaining or preceeding text
|
|
72
|
+
- Full (match only Full)
|
|
73
|
+
- Full% (match Full and any words after)
|
|
74
|
+
- %Full% ( match Full in the middle)
|
|
75
|
+
"""
|
|
76
|
+
query = "WHERE membership LIKE ?"
|
|
77
|
+
return self.run_count_query(query, (f"{membership_type}",))
|
|
78
|
+
|
|
66
79
|
def get_number_first_last(
|
|
67
80
|
self, first_name: str, last_name: str, found_error: bool = False
|
|
68
81
|
) -> Optional[int]:
|
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Merge import tests for MojoSkel
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from memberjojo.mojo_common import MojoSkel
|
|
7
|
+
|
|
8
|
+
from .utils import write_csv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_import_merge_csv(tmp_path):
|
|
12
|
+
"""
|
|
13
|
+
Test importing one CSV, then merging a second CSV,
|
|
14
|
+
and verify that it appends instead of replacing.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# 1. Original CSV
|
|
18
|
+
csv1 = tmp_path / "members1.csv"
|
|
19
|
+
original_rows = [
|
|
20
|
+
{"id": "1", "name": "Alice", "age": "30"},
|
|
21
|
+
]
|
|
22
|
+
write_csv(csv1, original_rows)
|
|
23
|
+
|
|
24
|
+
# Create DB
|
|
25
|
+
db_path = tmp_path / "test_merge.db"
|
|
26
|
+
password = "Needs a Password"
|
|
27
|
+
|
|
28
|
+
# Load initial CSV
|
|
29
|
+
m = MojoSkel(str(db_path), password, "members")
|
|
30
|
+
m.import_csv(csv1)
|
|
31
|
+
|
|
32
|
+
assert m.count() == 1
|
|
33
|
+
|
|
34
|
+
# 2. Second CSV to merge
|
|
35
|
+
csv2 = tmp_path / "members2.csv"
|
|
36
|
+
new_rows = [
|
|
37
|
+
{"id": "2", "name": "Bob", "age": "40"},
|
|
38
|
+
]
|
|
39
|
+
write_csv(csv2, new_rows)
|
|
40
|
+
|
|
41
|
+
# Import with merge=True
|
|
42
|
+
m.import_csv(csv2, merge=True)
|
|
43
|
+
|
|
44
|
+
# Should have 2 rows now
|
|
45
|
+
assert m.count() == 2
|
|
46
|
+
|
|
47
|
+
# Verify content
|
|
48
|
+
# We iterate over m which yields row objects
|
|
49
|
+
rows = list(m)
|
|
50
|
+
assert len(rows) == 2
|
|
51
|
+
names = {r.name for r in rows}
|
|
52
|
+
assert "Alice" in names
|
|
53
|
+
assert "Bob" in names
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_import_merge_csv_no_existing(tmp_path):
|
|
57
|
+
"""
|
|
58
|
+
Test merging into a non-existent table (should act like create).
|
|
59
|
+
"""
|
|
60
|
+
csv1 = tmp_path / "members_new.csv"
|
|
61
|
+
rows = [{"id": "1", "name": "Alice", "age": "30"}]
|
|
62
|
+
write_csv(csv1, rows)
|
|
63
|
+
|
|
64
|
+
db_path = tmp_path / "test_merge_new.db"
|
|
65
|
+
password = "Needs a Password"
|
|
66
|
+
|
|
67
|
+
m = MojoSkel(str(db_path), password, "members")
|
|
68
|
+
# Table doesn't exist yet
|
|
69
|
+
assert not m.table_exists()
|
|
70
|
+
|
|
71
|
+
m.import_csv(csv1, merge=True)
|
|
72
|
+
|
|
73
|
+
assert m.table_exists()
|
|
74
|
+
assert m.count() == 1
|
|
75
|
+
row = list(m)[0]
|
|
76
|
+
assert row.name == "Alice"
|
|
@@ -3,18 +3,9 @@
|
|
|
3
3
|
CSV diff tests for the Member class
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
import csv
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
6
|
from memberjojo.mojo_common import MojoSkel
|
|
10
7
|
|
|
11
|
-
|
|
12
|
-
def write_csv(path: Path, rows: list[dict]):
|
|
13
|
-
"""Utility: write CSV with DictWriter."""
|
|
14
|
-
with path.open("w", newline="", encoding="utf-8") as f:
|
|
15
|
-
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
|
|
16
|
-
writer.writeheader()
|
|
17
|
-
writer.writerows(rows)
|
|
8
|
+
from .utils import write_csv
|
|
18
9
|
|
|
19
10
|
|
|
20
11
|
def test_import_new_csv_generates_diff(tmp_path, capsys):
|
|
@@ -25,6 +25,7 @@ def mock_csv_file(tmp_path):
|
|
|
25
25
|
"Title",
|
|
26
26
|
"First name",
|
|
27
27
|
"Last name",
|
|
28
|
+
"Membership",
|
|
28
29
|
"membermojo ID",
|
|
29
30
|
"Short URL",
|
|
30
31
|
"Active Member",
|
|
@@ -36,6 +37,7 @@ def mock_csv_file(tmp_path):
|
|
|
36
37
|
"Title": "Mr",
|
|
37
38
|
"First name": "John",
|
|
38
39
|
"Last name": "Doe",
|
|
40
|
+
"Membership": "Full",
|
|
39
41
|
"membermojo ID": "1001",
|
|
40
42
|
"Short URL": "http://short.url/johndoe",
|
|
41
43
|
"Active Member": "yes",
|
|
@@ -46,6 +48,7 @@ def mock_csv_file(tmp_path):
|
|
|
46
48
|
"Title": "Ms",
|
|
47
49
|
"First name": "Jane",
|
|
48
50
|
"Last name": "Smith",
|
|
51
|
+
"Membership": "Full",
|
|
49
52
|
"membermojo ID": "1002",
|
|
50
53
|
"Short URL": "http://short.url/janesmith",
|
|
51
54
|
"Active Member": "no",
|
|
@@ -56,6 +59,7 @@ def mock_csv_file(tmp_path):
|
|
|
56
59
|
"Title": "Dr",
|
|
57
60
|
"First name": "Emily",
|
|
58
61
|
"Last name": "Stone",
|
|
62
|
+
"Membership": "Full",
|
|
59
63
|
"membermojo ID": "1001",
|
|
60
64
|
"Short URL": "http://short.url/emilystone",
|
|
61
65
|
"Active Member": "yes",
|
|
@@ -66,6 +70,7 @@ def mock_csv_file(tmp_path):
|
|
|
66
70
|
"Title": "Mrs",
|
|
67
71
|
"First name": "Sara",
|
|
68
72
|
"Last name": "Connor",
|
|
73
|
+
"Membership": "Half",
|
|
69
74
|
"membermojo ID": "1003",
|
|
70
75
|
"Short URL": "http://short.url/saraconnor",
|
|
71
76
|
"Active Member": "no",
|
|
@@ -76,6 +81,7 @@ def mock_csv_file(tmp_path):
|
|
|
76
81
|
"Title": "Sir",
|
|
77
82
|
"First name": "Rick",
|
|
78
83
|
"Last name": "Grimes",
|
|
84
|
+
"Membership": "Half",
|
|
79
85
|
"membermojo ID": "1004",
|
|
80
86
|
"Short URL": "http://short.url/rickgrimes",
|
|
81
87
|
"Active Member": "yes",
|
|
@@ -267,3 +273,11 @@ def test_get_fuzz_name_raises_value_error_when_no_match_and_found_error_true(mem
|
|
|
267
273
|
match=r"❌ Cannot find xyz123notfound in member database with fuzzy match\.",
|
|
268
274
|
):
|
|
269
275
|
member_db.get_fuzz_name("xyz123notfound", found_error=True)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_member_count(member_db):
|
|
279
|
+
"""
|
|
280
|
+
Test member_type_count returns the correct value
|
|
281
|
+
"""
|
|
282
|
+
assert member_db.member_type_count("half") == 2
|
|
283
|
+
assert member_db.member_type_count("noop") == 0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Utils for tests"""
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def write_csv(path: Path, rows: list[dict]):
|
|
9
|
+
"""Utility: write CSV with DictWriter."""
|
|
10
|
+
with path.open("w", newline="", encoding="utf-8") as f:
|
|
11
|
+
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
|
|
12
|
+
writer.writeheader()
|
|
13
|
+
writer.writerows(rows)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|