memberjojo 1.2__py3-none-any.whl → 2.1__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 +1 -1
- memberjojo/_version.py +2 -2
- memberjojo/mojo_common.py +183 -11
- memberjojo/mojo_loader.py +140 -0
- memberjojo/mojo_member.py +26 -141
- memberjojo/mojo_transaction.py +8 -218
- {memberjojo-1.2.dist-info → memberjojo-2.1.dist-info}/METADATA +24 -10
- memberjojo-2.1.dist-info/RECORD +11 -0
- memberjojo-1.2.dist-info/RECORD +0 -10
- {memberjojo-1.2.dist-info → memberjojo-2.1.dist-info}/WHEEL +0 -0
- {memberjojo-1.2.dist-info → memberjojo-2.1.dist-info}/licenses/LICENSE +0 -0
memberjojo/__init__.py
CHANGED
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 = '1
|
|
32
|
-
__version_tuple__ = version_tuple = (
|
|
31
|
+
__version__ = version = '2.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 1)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
memberjojo/mojo_common.py
CHANGED
|
@@ -5,7 +5,16 @@ This module provides a common base class (`MojoSkel`) for other `memberjojo` mod
|
|
|
5
5
|
It includes helper methods for working with SQLite databases.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# pylint: disable=no-member
|
|
9
|
+
|
|
10
|
+
from dataclasses import make_dataclass
|
|
11
|
+
from decimal import Decimal, InvalidOperation
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Union, List
|
|
14
|
+
|
|
15
|
+
from sqlcipher3 import dbapi2 as sqlite3
|
|
16
|
+
|
|
17
|
+
from . import mojo_loader
|
|
9
18
|
|
|
10
19
|
|
|
11
20
|
class MojoSkel:
|
|
@@ -14,7 +23,7 @@ class MojoSkel:
|
|
|
14
23
|
for querying tables.
|
|
15
24
|
"""
|
|
16
25
|
|
|
17
|
-
def __init__(self, db_path: str, table_name: str):
|
|
26
|
+
def __init__(self, db_path: str, db_key: str, table_name: str):
|
|
18
27
|
"""
|
|
19
28
|
Initialize the MojoSkel class.
|
|
20
29
|
|
|
@@ -22,13 +31,103 @@ class MojoSkel:
|
|
|
22
31
|
dictionary-style access to columns.
|
|
23
32
|
|
|
24
33
|
:param db_path: Path to the SQLite database file.
|
|
34
|
+
:param db_key: key to unlock the encrypted sqlite database, or encrypt new one.
|
|
25
35
|
:param table_name: Name of the table to operate on, or create when importing.
|
|
26
36
|
"""
|
|
27
|
-
self.
|
|
37
|
+
self.db_path = db_path
|
|
38
|
+
self.table_name = table_name
|
|
39
|
+
self.db_key = db_key
|
|
28
40
|
|
|
41
|
+
# Open connection
|
|
42
|
+
self.conn = sqlite3.connect(self.db_path)
|
|
29
43
|
self.conn.row_factory = sqlite3.Row
|
|
30
44
|
self.cursor = self.conn.cursor()
|
|
31
|
-
|
|
45
|
+
|
|
46
|
+
# Apply SQLCipher key
|
|
47
|
+
self.cursor.execute(f"PRAGMA key='{db_key}'")
|
|
48
|
+
self.cursor.execute("PRAGMA cipher_compatibility = 4")
|
|
49
|
+
print("Cipher:", self.cursor.execute("PRAGMA cipher_version;").fetchone()[0])
|
|
50
|
+
print(f"Encrypted database {self.db_path} loaded securely.")
|
|
51
|
+
|
|
52
|
+
# After table exists (or after import), build the dataclass
|
|
53
|
+
if self.table_exists(table_name):
|
|
54
|
+
self.row_class = self._build_dataclass_from_table()
|
|
55
|
+
else:
|
|
56
|
+
self.row_class = None
|
|
57
|
+
|
|
58
|
+
def __iter__(self):
|
|
59
|
+
"""
|
|
60
|
+
Allow iterating over the class, by outputing all members.
|
|
61
|
+
"""
|
|
62
|
+
if not self.row_class:
|
|
63
|
+
raise RuntimeError("Table not loaded yet — no dataclass available")
|
|
64
|
+
return self._iter_rows()
|
|
65
|
+
|
|
66
|
+
def _iter_rows(self):
|
|
67
|
+
"""
|
|
68
|
+
Iterate over table rows and yield dynamically-created dataclass objects.
|
|
69
|
+
Converts REAL columns to Decimal automatically.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
sql = f'SELECT * FROM "{self.table_name}"'
|
|
73
|
+
|
|
74
|
+
cur = self.conn.cursor()
|
|
75
|
+
cur.execute(sql)
|
|
76
|
+
|
|
77
|
+
for row in cur.fetchall():
|
|
78
|
+
row_dict = dict(row)
|
|
79
|
+
|
|
80
|
+
# Convert REAL → Decimal
|
|
81
|
+
for k, v in row_dict.items():
|
|
82
|
+
if isinstance(v, float):
|
|
83
|
+
row_dict[k] = Decimal(str(v))
|
|
84
|
+
elif isinstance(v, str):
|
|
85
|
+
# Try converting numeric strings
|
|
86
|
+
try:
|
|
87
|
+
row_dict[k] = Decimal(v)
|
|
88
|
+
except InvalidOperation:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
yield self.row_class(**row_dict)
|
|
92
|
+
|
|
93
|
+
def _build_dataclass_from_table(self):
|
|
94
|
+
"""
|
|
95
|
+
Dynamically create a dataclass from the table schema.
|
|
96
|
+
INTEGER → int
|
|
97
|
+
REAL → Decimal
|
|
98
|
+
TEXT → str
|
|
99
|
+
|
|
100
|
+
:return: A dataclass built from the table columns and types.
|
|
101
|
+
"""
|
|
102
|
+
self.cursor.execute(f'PRAGMA table_info("{self.table_name}")')
|
|
103
|
+
cols = self.cursor.fetchall()
|
|
104
|
+
|
|
105
|
+
if not cols:
|
|
106
|
+
raise ValueError(f"Table '{self.table_name}' does not exist")
|
|
107
|
+
|
|
108
|
+
fields = []
|
|
109
|
+
for _cid, name, col_type, _notnull, _dflt, _pk in cols:
|
|
110
|
+
t = col_type.upper()
|
|
111
|
+
|
|
112
|
+
if t.startswith("INT"):
|
|
113
|
+
py_type = int
|
|
114
|
+
elif t.startswith("REAL") or t.startswith("NUM") or t.startswith("DEC"):
|
|
115
|
+
py_type = Decimal
|
|
116
|
+
else:
|
|
117
|
+
py_type = str
|
|
118
|
+
|
|
119
|
+
fields.append((name, py_type))
|
|
120
|
+
|
|
121
|
+
return make_dataclass(f"{self.table_name}_Row", fields)
|
|
122
|
+
|
|
123
|
+
def import_csv(self, csv_path: Path):
|
|
124
|
+
"""
|
|
125
|
+
import the passed CSV into the sqlite database
|
|
126
|
+
|
|
127
|
+
:param csv_path: Path like path of csv file.
|
|
128
|
+
"""
|
|
129
|
+
mojo_loader.import_csv_helper(self.conn, self.table_name, csv_path)
|
|
130
|
+
self.row_class = self._build_dataclass_from_table()
|
|
32
131
|
|
|
33
132
|
def show_table(self, limit: int = 2):
|
|
34
133
|
"""
|
|
@@ -36,10 +135,11 @@ class MojoSkel:
|
|
|
36
135
|
|
|
37
136
|
:param limit: (optional) Number of rows to display. Defaults to 2.
|
|
38
137
|
"""
|
|
39
|
-
self.
|
|
40
|
-
|
|
138
|
+
if self.table_exists(self.table_name):
|
|
139
|
+
self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
|
|
140
|
+
rows = self.cursor.fetchall()
|
|
41
141
|
|
|
42
|
-
|
|
142
|
+
else:
|
|
43
143
|
print("(No data)")
|
|
44
144
|
return
|
|
45
145
|
|
|
@@ -48,8 +148,80 @@ class MojoSkel:
|
|
|
48
148
|
|
|
49
149
|
def count(self) -> int:
|
|
50
150
|
"""
|
|
51
|
-
|
|
151
|
+
:return: count of the number of rows in the table, or 0 if no table.
|
|
152
|
+
"""
|
|
153
|
+
if self.table_exists(self.table_name):
|
|
154
|
+
self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
|
|
155
|
+
result = self.cursor.fetchone()
|
|
156
|
+
return result[0] if result else 0
|
|
157
|
+
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
def get_row(self, entry_name: str, entry_value: str) -> dict:
|
|
161
|
+
"""
|
|
162
|
+
Retrieve a single row matching column = value (case-insensitive).
|
|
163
|
+
|
|
164
|
+
:param entry_name: Column name to filter by.
|
|
165
|
+
:param entry_value: Value to match.
|
|
166
|
+
|
|
167
|
+
:return: The matching row as a dictionary, or None if not found.
|
|
168
|
+
"""
|
|
169
|
+
if not entry_value:
|
|
170
|
+
return None
|
|
171
|
+
query = (
|
|
172
|
+
f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
|
|
173
|
+
)
|
|
174
|
+
self.cursor.execute(query, (entry_value,))
|
|
175
|
+
row = self.cursor.fetchone()
|
|
176
|
+
return dict(row) if row else None
|
|
177
|
+
|
|
178
|
+
def get_row_multi(
|
|
179
|
+
self, match_dict: dict, only_one: bool = True
|
|
180
|
+
) -> Union[sqlite3.Row, List[sqlite3.Row], None]:
|
|
181
|
+
"""
|
|
182
|
+
Retrieve one or many rows matching multiple column=value pairs.
|
|
183
|
+
|
|
184
|
+
:param match_dict: Dictionary of column names and values to match.
|
|
185
|
+
:param only_one: If True (default), return the first matching row.
|
|
186
|
+
If False, return a list of all matching rows.
|
|
187
|
+
|
|
188
|
+
:return:
|
|
189
|
+
- If only_one=True → a single sqlite3.Row or None
|
|
190
|
+
- If only_one=False → list of sqlite3.Row (may be empty)
|
|
191
|
+
"""
|
|
192
|
+
conditions = []
|
|
193
|
+
values = []
|
|
194
|
+
|
|
195
|
+
for col, val in match_dict.items():
|
|
196
|
+
if val is None or val == "":
|
|
197
|
+
conditions.append(f'"{col}" IS NULL')
|
|
198
|
+
else:
|
|
199
|
+
conditions.append(f'"{col}" = ?')
|
|
200
|
+
values.append(
|
|
201
|
+
float(val.quantize(Decimal("0.01")))
|
|
202
|
+
if isinstance(val, Decimal)
|
|
203
|
+
else val
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
base_query = (
|
|
207
|
+
f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)}'
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if only_one:
|
|
211
|
+
query = base_query + " LIMIT 1"
|
|
212
|
+
self.cursor.execute(query, values)
|
|
213
|
+
return self.cursor.fetchone()
|
|
214
|
+
|
|
215
|
+
# Return *all* rows
|
|
216
|
+
self.cursor.execute(base_query, values)
|
|
217
|
+
return self.cursor.fetchall()
|
|
218
|
+
|
|
219
|
+
def table_exists(self, table_name: str) -> bool:
|
|
220
|
+
"""
|
|
221
|
+
Return true or false if a table exists
|
|
52
222
|
"""
|
|
53
|
-
self.cursor.execute(
|
|
54
|
-
|
|
55
|
-
|
|
223
|
+
self.cursor.execute(
|
|
224
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1;",
|
|
225
|
+
(table_name,),
|
|
226
|
+
)
|
|
227
|
+
return self.cursor.fetchone() is not None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Helper module for importing a CSV into a SQLite database.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from csv import DictReader
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
from collections import defaultdict, Counter
|
|
10
|
+
|
|
11
|
+
# -----------------------
|
|
12
|
+
# Normalization & Type Guessing
|
|
13
|
+
# -----------------------
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize(name: str) -> str:
|
|
17
|
+
"""
|
|
18
|
+
Normalize a column name: lowercase, remove symbols, convert to snake case.
|
|
19
|
+
|
|
20
|
+
:param name: Raw name to normalize.
|
|
21
|
+
|
|
22
|
+
:return: Normalized lowercase string in snake case with no symbols.
|
|
23
|
+
"""
|
|
24
|
+
name = name.strip().lower()
|
|
25
|
+
name = re.sub(r"[^a-z0-9]+", "_", name)
|
|
26
|
+
return name.strip("_")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _guess_type(value: any) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Guess SQLite data type of a CSV value: 'INTEGER', 'REAL', or 'TEXT'.
|
|
32
|
+
"""
|
|
33
|
+
if value is None:
|
|
34
|
+
return "TEXT"
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
value = value.strip()
|
|
37
|
+
if value == "":
|
|
38
|
+
return "TEXT"
|
|
39
|
+
try:
|
|
40
|
+
int(value)
|
|
41
|
+
return "INTEGER"
|
|
42
|
+
except (ValueError, TypeError):
|
|
43
|
+
try:
|
|
44
|
+
float(value)
|
|
45
|
+
return "REAL"
|
|
46
|
+
except (ValueError, TypeError):
|
|
47
|
+
return "TEXT"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def infer_columns_from_rows(rows: list[dict]) -> dict[str, str]:
|
|
51
|
+
"""
|
|
52
|
+
Infer column types from CSV rows.
|
|
53
|
+
Returns mapping: normalized column name -> SQLite type.
|
|
54
|
+
"""
|
|
55
|
+
type_counters = defaultdict(Counter)
|
|
56
|
+
|
|
57
|
+
for row in rows:
|
|
58
|
+
for key, value in row.items():
|
|
59
|
+
norm_key = _normalize(key)
|
|
60
|
+
type_counters[norm_key][_guess_type(value)] += 1
|
|
61
|
+
|
|
62
|
+
inferred_cols = {}
|
|
63
|
+
for col, counter in type_counters.items():
|
|
64
|
+
if counter["TEXT"] == 0:
|
|
65
|
+
if counter["REAL"] > 0:
|
|
66
|
+
inferred_cols[col] = "REAL"
|
|
67
|
+
else:
|
|
68
|
+
inferred_cols[col] = "INTEGER"
|
|
69
|
+
else:
|
|
70
|
+
inferred_cols[col] = "TEXT"
|
|
71
|
+
return inferred_cols
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -----------------------
|
|
75
|
+
# Table Creation
|
|
76
|
+
# -----------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _create_table_from_columns(table_name: str, columns: dict[str, str]) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Generate CREATE TABLE SQL from column type mapping.
|
|
82
|
+
|
|
83
|
+
:param table_name: Table to use when creating columns.
|
|
84
|
+
:param columns: dict of columns to create.
|
|
85
|
+
|
|
86
|
+
:return: SQL commands to create the table.
|
|
87
|
+
"""
|
|
88
|
+
column_defs = [f'"{col}" {col_type}' for col, col_type in columns.items()]
|
|
89
|
+
return (
|
|
90
|
+
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n'
|
|
91
|
+
+ ",\n".join(column_defs)
|
|
92
|
+
+ "\n)"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -----------------------
|
|
97
|
+
# CSV Import
|
|
98
|
+
# -----------------------
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def import_csv_helper(conn, table_name: str, csv_path: Path):
|
|
102
|
+
"""
|
|
103
|
+
Import CSV into database using given cursor.
|
|
104
|
+
Column types inferred automatically.
|
|
105
|
+
|
|
106
|
+
:param conn: SQLite database connection to use.
|
|
107
|
+
:param table_name: Table to import the CSV into.
|
|
108
|
+
:param csv_path: Path like path of the CSV file to import.
|
|
109
|
+
"""
|
|
110
|
+
if not csv_path.exists():
|
|
111
|
+
raise FileNotFoundError(f"CSV file not found: {csv_path}")
|
|
112
|
+
|
|
113
|
+
# Read CSV rows
|
|
114
|
+
with csv_path.open(newline="", encoding="utf-8") as f:
|
|
115
|
+
reader = list(DictReader(f))
|
|
116
|
+
if not reader:
|
|
117
|
+
raise ValueError("CSV file is empty.")
|
|
118
|
+
inferred_cols = infer_columns_from_rows(reader)
|
|
119
|
+
|
|
120
|
+
cursor = conn.cursor()
|
|
121
|
+
# Drop existing table
|
|
122
|
+
cursor.execute(f'DROP TABLE IF EXISTS "{table_name}";')
|
|
123
|
+
|
|
124
|
+
# Create table
|
|
125
|
+
create_sql = _create_table_from_columns(table_name, inferred_cols)
|
|
126
|
+
cursor.execute(create_sql)
|
|
127
|
+
|
|
128
|
+
# Insert rows
|
|
129
|
+
cols = list(reader[0].keys())
|
|
130
|
+
norm_map = {c: _normalize(c) for c in cols}
|
|
131
|
+
colnames = ",".join(f'"{norm_map[c]}"' for c in cols)
|
|
132
|
+
placeholders = ",".join("?" for _ in cols)
|
|
133
|
+
insert_sql = f'INSERT INTO "{table_name}" ({colnames}) VALUES ({placeholders})'
|
|
134
|
+
|
|
135
|
+
for row in reader:
|
|
136
|
+
values = [row[c] if row[c] != "" else None for c in cols]
|
|
137
|
+
cursor.execute(insert_sql, values)
|
|
138
|
+
|
|
139
|
+
cursor.close()
|
|
140
|
+
conn.commit()
|
memberjojo/mojo_member.py
CHANGED
|
@@ -5,37 +5,11 @@ This module loads data from a `members.csv` file downloaded from Membermojo,
|
|
|
5
5
|
stores it in SQLite, and provides helper functions for member lookups.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from csv import DictReader
|
|
9
|
-
from dataclasses import dataclass
|
|
10
8
|
from pathlib import Path
|
|
11
9
|
from typing import Optional
|
|
12
|
-
import sqlite3
|
|
13
|
-
from .config import CSV_ENCODING # import encoding from config.py
|
|
14
10
|
from .mojo_common import MojoSkel
|
|
15
11
|
|
|
16
12
|
|
|
17
|
-
@dataclass
|
|
18
|
-
class MemberData:
|
|
19
|
-
"""
|
|
20
|
-
A dataclass to represent a single member's data for database operations.
|
|
21
|
-
|
|
22
|
-
Attributes:
|
|
23
|
-
member_num (int): Unique member number (primary key).
|
|
24
|
-
title (str): Title (e.g., Mr, Mrs, Ms).
|
|
25
|
-
first_name (str): Member's first name.
|
|
26
|
-
last_name (str): Member's last name.
|
|
27
|
-
membermojo_id (int): Unique Membermojo ID.
|
|
28
|
-
short_url (str): Short URL to Membermojo profile.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
member_num: int
|
|
32
|
-
title: str
|
|
33
|
-
first_name: str
|
|
34
|
-
last_name: str
|
|
35
|
-
membermojo_id: int
|
|
36
|
-
short_url: str
|
|
37
|
-
|
|
38
|
-
|
|
39
13
|
class Member(MojoSkel):
|
|
40
14
|
"""
|
|
41
15
|
Subclass of MojoSkel providing member-specific database functions.
|
|
@@ -45,53 +19,19 @@ class Member(MojoSkel):
|
|
|
45
19
|
|
|
46
20
|
:param member_db_path (Path): Path to the SQLite database file.
|
|
47
21
|
:param table_name (str): (optional) Table name to use. Defaults to "members".
|
|
22
|
+
:param db_key: (optional) key to unlock the encrypted sqlite database, unencrypted if unset.
|
|
48
23
|
"""
|
|
49
24
|
|
|
50
|
-
def __init__(
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
member_db_path: Path,
|
|
28
|
+
db_key: str,
|
|
29
|
+
table_name: str = "members",
|
|
30
|
+
):
|
|
51
31
|
"""
|
|
52
32
|
Initialize the Member database handler.
|
|
53
33
|
"""
|
|
54
|
-
super().__init__(member_db_path, table_name)
|
|
55
|
-
|
|
56
|
-
def __iter__(self):
|
|
57
|
-
"""
|
|
58
|
-
Allow iterating over the class, by outputing all members.
|
|
59
|
-
"""
|
|
60
|
-
sql = (
|
|
61
|
-
f"SELECT member_number, "
|
|
62
|
-
f"title, "
|
|
63
|
-
f"first_name, "
|
|
64
|
-
f"last_name, "
|
|
65
|
-
f"membermojo_id, "
|
|
66
|
-
f"short_url "
|
|
67
|
-
f'FROM "{self.table_name}"'
|
|
68
|
-
)
|
|
69
|
-
self.cursor.execute(sql)
|
|
70
|
-
rows = self.cursor.fetchall()
|
|
71
|
-
for row in rows:
|
|
72
|
-
yield MemberData(*row)
|
|
73
|
-
|
|
74
|
-
def _create_tables(self):
|
|
75
|
-
"""
|
|
76
|
-
Create the members table in the database if it doesn't exist.
|
|
77
|
-
|
|
78
|
-
The table includes member number, title, first/last names,
|
|
79
|
-
Membermojo ID, and a short profile URL.
|
|
80
|
-
"""
|
|
81
|
-
sql_statements = [
|
|
82
|
-
f"""CREATE TABLE IF NOT EXISTS "{self.table_name}" (
|
|
83
|
-
member_number INTEGER PRIMARY KEY,
|
|
84
|
-
title TEXT NOT NULL CHECK(title IN ('Dr', 'Mr', 'Mrs', 'Miss', 'Ms')),
|
|
85
|
-
first_name TEXT NOT NULL,
|
|
86
|
-
last_name TEXT NOT NULL,
|
|
87
|
-
membermojo_id INTEGER UNIQUE NOT NULL,
|
|
88
|
-
short_url TEXT NOT NULL
|
|
89
|
-
);"""
|
|
90
|
-
]
|
|
91
|
-
|
|
92
|
-
for statement in sql_statements:
|
|
93
|
-
self.cursor.execute(statement)
|
|
94
|
-
self.conn.commit()
|
|
34
|
+
super().__init__(member_db_path, db_key, table_name)
|
|
95
35
|
|
|
96
36
|
def get_number_first_last(
|
|
97
37
|
self, first_name: str, last_name: str, found_error: bool = False
|
|
@@ -108,9 +48,9 @@ class Member(MojoSkel):
|
|
|
108
48
|
:raises ValueError: If not found and `found_error` is True.
|
|
109
49
|
"""
|
|
110
50
|
sql = f"""
|
|
111
|
-
SELECT member_number
|
|
51
|
+
SELECT "member_number"
|
|
112
52
|
FROM "{self.table_name}"
|
|
113
|
-
WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?)
|
|
53
|
+
WHERE LOWER("first_name") = LOWER(?) AND LOWER("last_name") = LOWER(?)
|
|
114
54
|
"""
|
|
115
55
|
self.cursor.execute(sql, (first_name, last_name))
|
|
116
56
|
result = self.cursor.fetchone()
|
|
@@ -150,10 +90,10 @@ class Member(MojoSkel):
|
|
|
150
90
|
:return: Name on membermojo or None
|
|
151
91
|
"""
|
|
152
92
|
sql = f"""
|
|
153
|
-
SELECT first_name, last_name
|
|
93
|
+
SELECT "first_name", "last_name"
|
|
154
94
|
FROM "{self.table_name}"
|
|
155
|
-
WHERE LOWER(first_name) = LOWER(?)
|
|
156
|
-
AND LOWER(last_name) = LOWER(?)
|
|
95
|
+
WHERE LOWER("first_name") = LOWER(?)
|
|
96
|
+
AND LOWER("last_name") = LOWER(?)
|
|
157
97
|
"""
|
|
158
98
|
self.cursor.execute(sql, (first_name, last_name))
|
|
159
99
|
row = self.cursor.fetchone()
|
|
@@ -169,10 +109,10 @@ class Member(MojoSkel):
|
|
|
169
109
|
:return: Name on membermojo or None
|
|
170
110
|
"""
|
|
171
111
|
sql = f"""
|
|
172
|
-
SELECT first_name, last_name
|
|
112
|
+
SELECT "first_name", "last_name"
|
|
173
113
|
FROM "{self.table_name}"
|
|
174
|
-
WHERE LOWER(first_name) LIKE LOWER(?) || '%'
|
|
175
|
-
AND LOWER(last_name) = LOWER(?)
|
|
114
|
+
WHERE LOWER("first_name") LIKE LOWER(?) || '%'
|
|
115
|
+
AND LOWER("last_name") = LOWER(?)
|
|
176
116
|
LIMIT 1
|
|
177
117
|
"""
|
|
178
118
|
self.cursor.execute(sql, (letter, last_name))
|
|
@@ -184,11 +124,14 @@ class Member(MojoSkel):
|
|
|
184
124
|
) -> Optional[tuple]:
|
|
185
125
|
"""
|
|
186
126
|
Resolve a member name from a free-text full name.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
127
|
+
|
|
128
|
+
**Search order**
|
|
129
|
+
|
|
130
|
+
1. first + last
|
|
131
|
+
2. middle + last (if three parts)
|
|
132
|
+
3. initial 1st letter + last
|
|
133
|
+
4. initial 2nd letter + last (for two-letter initials)
|
|
134
|
+
|
|
192
135
|
Returns (first_name, last_name) or None.
|
|
193
136
|
|
|
194
137
|
:param full_name: Full name of the member to find.
|
|
@@ -271,9 +214,9 @@ class Member(MojoSkel):
|
|
|
271
214
|
:return: Full name as tuple, or None if not found.
|
|
272
215
|
"""
|
|
273
216
|
sql = f"""
|
|
274
|
-
SELECT first_name, last_name
|
|
217
|
+
SELECT "first_name", "last_name"
|
|
275
218
|
FROM "{self.table_name}"
|
|
276
|
-
WHERE member_number = ?
|
|
219
|
+
WHERE "member_number" = ?
|
|
277
220
|
"""
|
|
278
221
|
self.cursor.execute(sql, (member_number,))
|
|
279
222
|
result = self.cursor.fetchone()
|
|
@@ -295,61 +238,3 @@ class Member(MojoSkel):
|
|
|
295
238
|
first_name, last_name = result
|
|
296
239
|
return f"{first_name} {last_name}"
|
|
297
240
|
return None
|
|
298
|
-
|
|
299
|
-
def _add(self, member: MemberData):
|
|
300
|
-
"""
|
|
301
|
-
Insert a member into the database if not already present.
|
|
302
|
-
|
|
303
|
-
:param member: The member to add.
|
|
304
|
-
"""
|
|
305
|
-
sql = f"""INSERT OR ABORT INTO "{self.table_name}"
|
|
306
|
-
(member_number, title, first_name, last_name, membermojo_id, short_url)
|
|
307
|
-
VALUES (?, ?, ?, ?, ?, ?)"""
|
|
308
|
-
|
|
309
|
-
try:
|
|
310
|
-
self.cursor.execute(
|
|
311
|
-
sql,
|
|
312
|
-
(
|
|
313
|
-
member.member_num,
|
|
314
|
-
member.title,
|
|
315
|
-
member.first_name,
|
|
316
|
-
member.last_name,
|
|
317
|
-
member.membermojo_id,
|
|
318
|
-
member.short_url,
|
|
319
|
-
),
|
|
320
|
-
)
|
|
321
|
-
self.conn.commit()
|
|
322
|
-
print(
|
|
323
|
-
f"Created user {member.member_num}: {member.first_name} {member.last_name}"
|
|
324
|
-
)
|
|
325
|
-
except sqlite3.IntegrityError:
|
|
326
|
-
pass
|
|
327
|
-
|
|
328
|
-
def import_csv(self, csv_path: Path):
|
|
329
|
-
"""
|
|
330
|
-
Load members from a Membermojo CSV file and insert into the database.
|
|
331
|
-
|
|
332
|
-
:param csv_path: Path to the CSV file.
|
|
333
|
-
|
|
334
|
-
Notes:
|
|
335
|
-
Only adds members not already in the database (INSERT OR ABORT).
|
|
336
|
-
"""
|
|
337
|
-
print(f"Using SQLite database version {sqlite3.sqlite_version}")
|
|
338
|
-
self._create_tables()
|
|
339
|
-
|
|
340
|
-
try:
|
|
341
|
-
with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
|
|
342
|
-
mojo_reader = DictReader(csvfile)
|
|
343
|
-
|
|
344
|
-
for row in mojo_reader:
|
|
345
|
-
member = MemberData(
|
|
346
|
-
member_num=int(row["Member number"]),
|
|
347
|
-
title=row["Title"].strip(),
|
|
348
|
-
first_name=row["First name"].strip(),
|
|
349
|
-
last_name=row["Last name"].strip(),
|
|
350
|
-
membermojo_id=int(row["membermojo ID"]),
|
|
351
|
-
short_url=row["Short URL"].strip(),
|
|
352
|
-
)
|
|
353
|
-
self._add(member)
|
|
354
|
-
except FileNotFoundError:
|
|
355
|
-
print(f"CSV file not found: {csv_path}")
|
memberjojo/mojo_transaction.py
CHANGED
|
@@ -5,13 +5,6 @@ Provides automatic column type inference, robust CSV importing, and
|
|
|
5
5
|
helper methods for querying the database.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from collections import Counter, defaultdict
|
|
9
|
-
from csv import DictReader
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
from sqlite3 import IntegrityError, OperationalError, DatabaseError
|
|
12
|
-
from decimal import Decimal
|
|
13
|
-
|
|
14
|
-
from .config import CSV_ENCODING # import encoding from config.py
|
|
15
8
|
from .mojo_common import MojoSkel
|
|
16
9
|
|
|
17
10
|
|
|
@@ -24,219 +17,16 @@ class Transaction(MojoSkel):
|
|
|
24
17
|
|
|
25
18
|
:param payment_db_path: Path to the SQLite database.
|
|
26
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.
|
|
27
21
|
"""
|
|
28
22
|
|
|
29
|
-
def __init__(
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
payment_db_path: str,
|
|
26
|
+
db_key: str,
|
|
27
|
+
table_name: str = "payments",
|
|
28
|
+
):
|
|
30
29
|
"""
|
|
31
30
|
Initialize the Transaction object.
|
|
32
31
|
"""
|
|
33
|
-
super().__init__(payment_db_path, table_name)
|
|
34
|
-
self.columns = {}
|
|
35
|
-
|
|
36
|
-
def _guess_type(self, value: any) -> str:
|
|
37
|
-
"""
|
|
38
|
-
Guess the SQLite data type of a CSV field value.
|
|
39
|
-
|
|
40
|
-
:param value: The value from a CSV field.
|
|
41
|
-
|
|
42
|
-
:return: One of 'INTEGER', 'REAL', or 'TEXT'.
|
|
43
|
-
"""
|
|
44
|
-
if value is None:
|
|
45
|
-
return "TEXT"
|
|
46
|
-
|
|
47
|
-
if isinstance(value, str):
|
|
48
|
-
value = value.strip()
|
|
49
|
-
if value == "":
|
|
50
|
-
return "TEXT"
|
|
51
|
-
|
|
52
|
-
try:
|
|
53
|
-
int(value)
|
|
54
|
-
return "INTEGER"
|
|
55
|
-
except (ValueError, TypeError):
|
|
56
|
-
try:
|
|
57
|
-
float(value)
|
|
58
|
-
return "REAL"
|
|
59
|
-
except (ValueError, TypeError):
|
|
60
|
-
return "TEXT"
|
|
61
|
-
|
|
62
|
-
def _infer_columns_from_rows(self, rows: list[dict]):
|
|
63
|
-
"""
|
|
64
|
-
Infer SQLite column types based on sample CSV data.
|
|
65
|
-
|
|
66
|
-
:param rows: Sample rows from CSV to analyze.
|
|
67
|
-
"""
|
|
68
|
-
type_counters = defaultdict(Counter)
|
|
69
|
-
|
|
70
|
-
for row in rows:
|
|
71
|
-
for key, value in row.items():
|
|
72
|
-
guessed_type = self._guess_type(value)
|
|
73
|
-
type_counters[key][guessed_type] += 1
|
|
74
|
-
|
|
75
|
-
self.columns = {}
|
|
76
|
-
for col, counter in type_counters.items():
|
|
77
|
-
if counter["TEXT"] == 0:
|
|
78
|
-
if counter["REAL"] > 0:
|
|
79
|
-
self.columns[col] = "REAL"
|
|
80
|
-
else:
|
|
81
|
-
self.columns[col] = "INTEGER"
|
|
82
|
-
else:
|
|
83
|
-
self.columns[col] = "TEXT"
|
|
84
|
-
|
|
85
|
-
print("Inferred columns:", self.columns)
|
|
86
|
-
|
|
87
|
-
def _create_tables(self, table: str, primary_col: str):
|
|
88
|
-
"""
|
|
89
|
-
Create the table if it doesn't exist, using inferred schema.
|
|
90
|
-
|
|
91
|
-
:param table: Table name.
|
|
92
|
-
:param primary_col: Column to use as primary key, or None for default.
|
|
93
|
-
"""
|
|
94
|
-
col_names = list(self.columns.keys())
|
|
95
|
-
if primary_col is None:
|
|
96
|
-
primary_col = col_names[0]
|
|
97
|
-
elif primary_col not in self.columns:
|
|
98
|
-
raise ValueError(f"Primary key column '{primary_col}' not found in CSV.")
|
|
99
|
-
|
|
100
|
-
cols_def = ", ".join(
|
|
101
|
-
f'"{col}" {col_type}{" PRIMARY KEY" if col == primary_col else ""}'
|
|
102
|
-
for col, col_type in self.columns.items()
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
self.cursor.execute(f'CREATE TABLE IF NOT EXISTS "{table}" ({cols_def})')
|
|
106
|
-
self.conn.commit()
|
|
107
|
-
|
|
108
|
-
def _parse_row_values(self, row: dict, column_types: dict) -> tuple:
|
|
109
|
-
"""
|
|
110
|
-
Convert CSV row string values to types suitable for SQLite.
|
|
111
|
-
|
|
112
|
-
:param row: A dictionary from the CSV row.
|
|
113
|
-
:param column_types: Mapping of column names to SQLite types.
|
|
114
|
-
|
|
115
|
-
:return: Parsed values.
|
|
116
|
-
"""
|
|
117
|
-
values = []
|
|
118
|
-
for col, col_type in column_types.items():
|
|
119
|
-
val = row.get(col, "")
|
|
120
|
-
if val is None or val.strip() == "":
|
|
121
|
-
values.append(None)
|
|
122
|
-
elif col_type == "REAL":
|
|
123
|
-
values.append(float(val))
|
|
124
|
-
elif col_type == "INTEGER":
|
|
125
|
-
values.append(int(val))
|
|
126
|
-
else:
|
|
127
|
-
values.append(val)
|
|
128
|
-
return tuple(values)
|
|
129
|
-
|
|
130
|
-
def import_csv(self, csv_path: Path, pk_column: str = None, sample_size: int = 100):
|
|
131
|
-
"""
|
|
132
|
-
Import a completed_payments.csv file into SQLite.
|
|
133
|
-
|
|
134
|
-
Infers column types and logs failed rows. Creates the table if needed.
|
|
135
|
-
|
|
136
|
-
:param csv_path: Path to the CSV file.
|
|
137
|
-
:param pk_column: (optional) Primary key column name. Defaults to the first column.
|
|
138
|
-
:param sample_size: (optional) Number of rows to sample for type inference. Defaults to 100.
|
|
139
|
-
|
|
140
|
-
:raises ValueError: If the CSV is empty or contains failed insertions.
|
|
141
|
-
"""
|
|
142
|
-
try:
|
|
143
|
-
# First pass: infer schema from sample
|
|
144
|
-
with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
|
|
145
|
-
reader = DictReader(csvfile)
|
|
146
|
-
sample_rows = [row for _, row in zip(range(sample_size), reader)]
|
|
147
|
-
if not sample_rows:
|
|
148
|
-
raise ValueError("CSV file is empty.")
|
|
149
|
-
|
|
150
|
-
# Only infer columns if not already set (i.e., first import)
|
|
151
|
-
if not self.columns:
|
|
152
|
-
self._infer_columns_from_rows(sample_rows)
|
|
153
|
-
|
|
154
|
-
except FileNotFoundError:
|
|
155
|
-
print(f"CSV file not found: {csv_path}")
|
|
156
|
-
return
|
|
157
|
-
|
|
158
|
-
# Create table
|
|
159
|
-
self._create_tables(self.table_name, pk_column)
|
|
160
|
-
|
|
161
|
-
# Count rows before import
|
|
162
|
-
count_before = self.count()
|
|
163
|
-
|
|
164
|
-
# Second pass: insert all rows
|
|
165
|
-
failed_rows = []
|
|
166
|
-
with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
|
|
167
|
-
reader = DictReader(csvfile)
|
|
168
|
-
column_list = ", ".join(f'"{col}"' for col in self.columns)
|
|
169
|
-
placeholders = ", ".join(["?"] * len(self.columns))
|
|
170
|
-
insert_stmt = f'INSERT OR IGNORE INTO "{self.table_name}" ({column_list}) \
|
|
171
|
-
VALUES ({placeholders})'
|
|
172
|
-
|
|
173
|
-
for row in reader:
|
|
174
|
-
try:
|
|
175
|
-
self.cursor.execute(
|
|
176
|
-
insert_stmt, self._parse_row_values(row, self.columns)
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
except (
|
|
180
|
-
IntegrityError,
|
|
181
|
-
OperationalError,
|
|
182
|
-
DatabaseError,
|
|
183
|
-
ValueError,
|
|
184
|
-
) as e:
|
|
185
|
-
failed_rows.append((row.copy(), str(e)))
|
|
186
|
-
|
|
187
|
-
self.conn.commit()
|
|
188
|
-
|
|
189
|
-
print(
|
|
190
|
-
f"Inserted {self.count() - count_before} new rows into '{self.table_name}'."
|
|
191
|
-
)
|
|
192
|
-
|
|
193
|
-
if failed_rows:
|
|
194
|
-
print(f"{len(failed_rows)} rows failed to insert:")
|
|
195
|
-
for row, error in failed_rows[:5]:
|
|
196
|
-
print(f"Failed: {error} | Data: {row}")
|
|
197
|
-
if len(failed_rows) > 5:
|
|
198
|
-
print(f"... and {len(failed_rows) - 5} more")
|
|
199
|
-
raise ValueError(f"Failed to import: {csv_path}")
|
|
200
|
-
|
|
201
|
-
def get_row(self, entry_name: str, entry_value: str) -> dict:
|
|
202
|
-
"""
|
|
203
|
-
Retrieve a single row matching column = value (case-insensitive).
|
|
204
|
-
|
|
205
|
-
:param entry_name: Column name to filter by.
|
|
206
|
-
:param entry_value: Value to match.
|
|
207
|
-
|
|
208
|
-
:return: The matching row as a dictionary, or None if not found.
|
|
209
|
-
"""
|
|
210
|
-
if not entry_value:
|
|
211
|
-
return None
|
|
212
|
-
query = (
|
|
213
|
-
f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
|
|
214
|
-
)
|
|
215
|
-
self.cursor.execute(query, (entry_value,))
|
|
216
|
-
row = self.cursor.fetchone()
|
|
217
|
-
return dict(row) if row else None
|
|
218
|
-
|
|
219
|
-
def get_row_multi(self, match_dict: dict) -> dict:
|
|
220
|
-
"""
|
|
221
|
-
Retrieve the first row matching multiple column = value pairs.
|
|
222
|
-
|
|
223
|
-
:param match_dict: Dictionary of column names and values to match.
|
|
224
|
-
|
|
225
|
-
:return: The first matching row, or None if not found.
|
|
226
|
-
"""
|
|
227
|
-
conditions = []
|
|
228
|
-
values = []
|
|
229
|
-
for col, val in match_dict.items():
|
|
230
|
-
if val is None or val == "":
|
|
231
|
-
conditions.append(f'"{col}" IS NULL')
|
|
232
|
-
else:
|
|
233
|
-
conditions.append(f'"{col}" = ?')
|
|
234
|
-
values.append(
|
|
235
|
-
float(val.quantize(Decimal("0.01")))
|
|
236
|
-
if isinstance(val, Decimal)
|
|
237
|
-
else val
|
|
238
|
-
)
|
|
239
|
-
|
|
240
|
-
query = f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)} LIMIT 1'
|
|
241
|
-
self.cursor.execute(query, values)
|
|
242
|
-
return self.cursor.fetchone()
|
|
32
|
+
super().__init__(payment_db_path, db_key, table_name)
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memberjojo
|
|
3
|
-
Version: 1
|
|
3
|
+
Version: 2.1
|
|
4
4
|
Summary: memberjojo - tools for working with members.
|
|
5
5
|
Author-email: Duncan Bellamy <dunk@denkimushi.com>
|
|
6
6
|
Requires-Python: >=3.8
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
License-Expression: MIT
|
|
9
9
|
License-File: LICENSE
|
|
10
|
+
Requires-Dist: sqlcipher3
|
|
10
11
|
Requires-Dist: coverage ; extra == "dev"
|
|
11
12
|
Requires-Dist: flit ; extra == "dev"
|
|
12
13
|
Requires-Dist: pytest ; extra == "dev"
|
|
@@ -23,16 +24,15 @@ Provides-Extra: lint
|
|
|
23
24
|
|
|
24
25
|
# memberjojo
|
|
25
26
|
|
|
26
|
-
`memberjojo` is a Python library for
|
|
27
|
+
`memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
|
|
27
28
|
data from CSV imports.\
|
|
28
|
-
It provides member database
|
|
29
|
-
This is done in a local SQLite database, and does not alter
|
|
29
|
+
It provides member database, and completed payments querying.\
|
|
30
|
+
This is done in a local SQLite database which is encrypted, and does not alter
|
|
31
|
+
anything on Membermojo.\
|
|
30
32
|
It provides tools to load, and query membership and transaction data efficiently
|
|
31
33
|
without having to use SQLite directly.\
|
|
32
|
-
When importing CSV files existing entries are
|
|
33
|
-
latest download and the local database is updated
|
|
34
|
-
All the transaction data is imported into the database,
|
|
35
|
-
but currently only a limited amount of member data is imported.
|
|
34
|
+
When importing CSV files existing entries are dropped before import, so you can
|
|
35
|
+
just import the latest download and the local database is updated.\
|
|
36
36
|
|
|
37
37
|
---
|
|
38
38
|
|
|
@@ -40,7 +40,21 @@ but currently only a limited amount of member data is imported.
|
|
|
40
40
|
|
|
41
41
|
Install via `pip`:
|
|
42
42
|
|
|
43
|
+
Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
|
|
44
|
+
(The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` is needed
|
|
45
|
+
for 'clang' to be able to find the header files)\
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
brew install sqlcipher
|
|
49
|
+
export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
|
|
50
|
+
export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
|
|
51
|
+
pip install memberjojo
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Installing via `pip` on ubuntu:
|
|
55
|
+
|
|
43
56
|
```bash
|
|
57
|
+
sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
|
|
44
58
|
pip install memberjojo
|
|
45
59
|
```
|
|
46
60
|
|
|
@@ -64,11 +78,11 @@ from membermojo import Member
|
|
|
64
78
|
member_database_path = Path(Path(__file__).parent, "database", "my-members.db")
|
|
65
79
|
member_csv_path = Path("download", "members.csv")
|
|
66
80
|
|
|
67
|
-
members = Member(member_database_path)
|
|
81
|
+
members = Member(member_database_path, "My DB Password")
|
|
68
82
|
members.import_csv(member_csv_path)
|
|
69
83
|
|
|
70
84
|
for member in members:
|
|
71
|
-
print(member.first_name, member.last_name, member.
|
|
85
|
+
print(member.first_name, member.last_name, member.member_number)
|
|
72
86
|
|
|
73
87
|
# Get full name for a given member number
|
|
74
88
|
found_name = members.get_name(1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
memberjojo/__init__.py,sha256=JGhIJ1HGbzrwzF4HsopOdP4eweLjUmFjbIDvMbk1MKI,291
|
|
2
|
+
memberjojo/_version.py,sha256=sdXMKvhafMmgXhpEPosw8Vx1-ID_v-Fo2LNHMrsKPJc,699
|
|
3
|
+
memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
|
|
4
|
+
memberjojo/mojo_common.py,sha256=uisTDjc7SCcTIecF35issytUzD8cHn3Tx7YbDZkp64E,7402
|
|
5
|
+
memberjojo/mojo_loader.py,sha256=PZRrdjCCqatrGbqyqPoI5Pg6IWmeVduqyE8zV9GwKlg,3935
|
|
6
|
+
memberjojo/mojo_member.py,sha256=3UeBIVtSXBCUpvuGN9fIJ0zgXu6vXhs4wFDHeDnOG0o,8078
|
|
7
|
+
memberjojo/mojo_transaction.py,sha256=EuP_iu5R5HHCkr6PaJdWG0BP-XFMAjl-YPuCDVnKaRE,916
|
|
8
|
+
memberjojo-2.1.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
9
|
+
memberjojo-2.1.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
10
|
+
memberjojo-2.1.dist-info/METADATA,sha256=3acNsTk91IdcxAHJ0-a5mjS74VjittHIcWD17gY3H7g,3602
|
|
11
|
+
memberjojo-2.1.dist-info/RECORD,,
|
memberjojo-1.2.dist-info/RECORD
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
memberjojo/__init__.py,sha256=KfW3YTaJkEfdjr1Yf9Lf1jBrBErvCbZ7p-1Y6qzEIOY,303
|
|
2
|
-
memberjojo/_version.py,sha256=HQKKxCSMqVTOOMMdZI6WQjmRkHgmuUGY2FWf9ey1h9Y,699
|
|
3
|
-
memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
|
|
4
|
-
memberjojo/mojo_common.py,sha256=hSMNggC1BOTLOeRQlv4LOC7gqfM0GMRdoHHT5PnuakA,1597
|
|
5
|
-
memberjojo/mojo_member.py,sha256=mlv847JW0KaeKJ1MtA_x2TlgUr2q6Pie_LiCyZYRniM,11929
|
|
6
|
-
memberjojo/mojo_transaction.py,sha256=772QU3O2xQAzgddRRBqf4UTjXwxKoSZTbHndvMSHcMM,8379
|
|
7
|
-
memberjojo-1.2.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
|
|
8
|
-
memberjojo-1.2.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
|
|
9
|
-
memberjojo-1.2.dist-info/METADATA,sha256=1__DegGgyFuh4CXIrBEdShdhTLOsZ_Ls-N8AEXI_xoo,3177
|
|
10
|
-
memberjojo-1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|