memberjojo 1.2__tar.gz → 2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memberjojo
3
- Version: 1.2
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 managing [Membermojo](http://membermojo.co.uk/)
27
+ `memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
27
28
  data from CSV imports.\
28
- It provides member database interactions, and transaction querying.\
29
- This is done in a local SQLite database, and does not alter anything on Membermojo.\
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 skipped, so you can just import the
33
- latest download and the local database is updated with new entries.\
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.member_num)
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)
@@ -1,15 +1,14 @@
1
1
  # memberjojo
2
2
 
3
- `memberjojo` is a Python library for managing [Membermojo](http://membermojo.co.uk/)
3
+ `memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
4
4
  data from CSV imports.\
5
- It provides member database interactions, and transaction querying.\
6
- This is done in a local SQLite database, and does not alter anything on Membermojo.\
5
+ It provides member database, and completed payments querying.\
6
+ This is done in a local SQLite database which is encrypted, and does not alter
7
+ anything on Membermojo.\
7
8
  It provides tools to load, and query membership and transaction data efficiently
8
9
  without having to use SQLite directly.\
9
- When importing CSV files existing entries are skipped, so you can just import the
10
- latest download and the local database is updated with new entries.\
11
- All the transaction data is imported into the database,
12
- but currently only a limited amount of member data is imported.
10
+ When importing CSV files existing entries are dropped before import, so you can
11
+ just import the latest download and the local database is updated.\
13
12
 
14
13
  ---
15
14
 
@@ -17,7 +16,21 @@ but currently only a limited amount of member data is imported.
17
16
 
18
17
  Install via `pip`:
19
18
 
19
+ Installing via `pip` on macos with `sqlcipher` installed via homebrew:\
20
+ (The sqlcipher bindings are compiled by pip so the `C_INCLUDE_PATH` is needed
21
+ for 'clang' to be able to find the header files)\
22
+
23
+ ```bash
24
+ brew install sqlcipher
25
+ export C_INCLUDE_PATH="/opt/homebrew/opt/sqlcipher/include"
26
+ export LIBRARY_PATH="/opt/homebrew/opt/sqlcipher/lib"
27
+ pip install memberjojo
28
+ ```
29
+
30
+ Installing via `pip` on ubuntu:
31
+
20
32
  ```bash
33
+ sudo apt-get --no-install-recommends --no-install-suggests install libsqlcipher-dev
21
34
  pip install memberjojo
22
35
  ```
23
36
 
@@ -41,11 +54,11 @@ from membermojo import Member
41
54
  member_database_path = Path(Path(__file__).parent, "database", "my-members.db")
42
55
  member_csv_path = Path("download", "members.csv")
43
56
 
44
- members = Member(member_database_path)
57
+ members = Member(member_database_path, "My DB Password")
45
58
  members.import_csv(member_csv_path)
46
59
 
47
60
  for member in members:
48
- print(member.first_name, member.last_name, member.member_num)
61
+ print(member.first_name, member.last_name, member.member_number)
49
62
 
50
63
  # Get full name for a given member number
51
64
  found_name = members.get_name(1)
@@ -9,6 +9,7 @@ readme = "README.md"
9
9
  license = "MIT"
10
10
  license-files = ["LICENSE"]
11
11
  dynamic = ["version", "description"]
12
+ dependencies = ["sqlcipher3"]
12
13
  requires-python = ">=3.8"
13
14
 
14
15
  [project.urls]
@@ -8,5 +8,5 @@ except ModuleNotFoundError:
8
8
  # _version.py is written when building dist
9
9
  __version__ = "0.0.0+local"
10
10
 
11
- from .mojo_member import Member, MemberData
11
+ from .mojo_member import Member
12
12
  from .mojo_transaction import Transaction
@@ -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.2'
32
- __version_tuple__ = version_tuple = (1, 2)
31
+ __version__ = version = '2.1'
32
+ __version_tuple__ = version_tuple = (2, 1)
33
33
 
34
- __commit_id__ = commit_id = 'g30b3ecb9d'
34
+ __commit_id__ = commit_id = 'gcf0cc58f7'
@@ -0,0 +1,227 @@
1
+ """
2
+ MojoSkel base class
3
+
4
+ This module provides a common base class (`MojoSkel`) for other `memberjojo` modules.
5
+ It includes helper methods for working with SQLite databases.
6
+ """
7
+
8
+ # 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
18
+
19
+
20
+ class MojoSkel:
21
+ """
22
+ Establishes a connection to a SQLite database and provides helper methods
23
+ for querying tables.
24
+ """
25
+
26
+ def __init__(self, db_path: str, db_key: str, table_name: str):
27
+ """
28
+ Initialize the MojoSkel class.
29
+
30
+ Connects to the SQLite database and sets the row factory for
31
+ dictionary-style access to columns.
32
+
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.
36
+ """
37
+ self.db_path = db_path
38
+ self.table_name = table_name
39
+ self.db_key = db_key
40
+
41
+ # Open connection
42
+ self.conn = sqlite3.connect(self.db_path)
43
+ self.conn.row_factory = sqlite3.Row
44
+ self.cursor = self.conn.cursor()
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()
131
+
132
+ def show_table(self, limit: int = 2):
133
+ """
134
+ Print the first few rows of the table as dictionaries.
135
+
136
+ :param limit: (optional) Number of rows to display. Defaults to 2.
137
+ """
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()
141
+
142
+ else:
143
+ print("(No data)")
144
+ return
145
+
146
+ for row in rows:
147
+ print(dict(row))
148
+
149
+ def count(self) -> int:
150
+ """
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
222
+ """
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()