memberjojo 1.0__py2.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 ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ memberjojo - tools for working with members.
3
+ """
4
+
5
+ try:
6
+ from ._version import version as __version__
7
+ except ModuleNotFoundError:
8
+ # _version.py is written when building dist
9
+ __version__ = "0.0.0+local"
10
+
11
+ from .mojo_member import Member, MemberData
12
+ from .mojo_transaction import Transaction
memberjojo/_version.py ADDED
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '1.0'
21
+ __version_tuple__ = version_tuple = (1, 0)
memberjojo/config.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Default encoding for importing CSV files
3
+ """
4
+
5
+ CSV_ENCODING = "UTF-8"
@@ -0,0 +1,55 @@
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
+ import sqlite3
9
+
10
+
11
+ class MojoSkel:
12
+ """
13
+ Establishes a connection to a SQLite database and provides helper methods
14
+ for querying tables.
15
+ """
16
+
17
+ def __init__(self, db_path: str, table_name: str):
18
+ """
19
+ Initialize the MojoSkel class.
20
+
21
+ Connects to the SQLite database and sets the row factory for
22
+ dictionary-style access to columns.
23
+
24
+ :param db_path: Path to the SQLite database file.
25
+ :param table_name: Name of the table to operate on, or create when importing.
26
+ """
27
+ self.conn = sqlite3.connect(db_path)
28
+
29
+ self.conn.row_factory = sqlite3.Row
30
+ self.cursor = self.conn.cursor()
31
+ self.table_name = table_name
32
+
33
+ def show_table(self, limit: int = 2):
34
+ """
35
+ Print the first few rows of the table as dictionaries.
36
+
37
+ :param limit: (optional) Number of rows to display. Defaults to 2.
38
+ """
39
+ self.cursor.execute(f'SELECT * FROM "{self.table_name}" LIMIT ?', (limit,))
40
+ rows = self.cursor.fetchall()
41
+
42
+ if not rows:
43
+ print("(No data)")
44
+ return
45
+
46
+ for row in rows:
47
+ print(dict(row))
48
+
49
+ def count(self) -> int:
50
+ """
51
+ Returns count of the number of rows in the table.
52
+ """
53
+ self.cursor.execute(f'SELECT COUNT(*) FROM "{self.table_name}"')
54
+ result = self.cursor.fetchone()
55
+ return result[0] if result else 0
@@ -0,0 +1,237 @@
1
+ """
2
+ Member module for creating and interacting with a SQLite database.
3
+
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.
6
+ """
7
+
8
+ from csv import DictReader
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Optional
12
+ import sqlite3
13
+ from .config import CSV_ENCODING # import encoding from config.py
14
+ from .mojo_common import MojoSkel
15
+
16
+
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
+ class Member(MojoSkel):
40
+ """
41
+ Subclass of MojoSkel providing member-specific database functions.
42
+
43
+ This class connects to a SQLite database and supports importing member data
44
+ from CSV and performing queries like lookup by name or member number.
45
+
46
+ :param member_db_path (Path): Path to the SQLite database file.
47
+ :param table_name (str): (optional) Table name to use. Defaults to "members".
48
+ """
49
+
50
+ def __init__(self, member_db_path: Path, table_name: str = "members"):
51
+ """
52
+ Initialize the Member database handler.
53
+ """
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()
95
+
96
+ def get_number_first_last(
97
+ self, first_name: str, last_name: str, found_error: bool = False
98
+ ) -> Optional[int]:
99
+ """
100
+ Find a member number based on first and last name (case-insensitive).
101
+
102
+ :param first_name: First name of the member.
103
+ :param last_name: Last name of the member.
104
+ :param found_error: (optional): If True, raises ValueError if not found.
105
+
106
+ :return: The member number if found, otherwise None.
107
+
108
+ :raises ValueError: If not found and `found_error` is True.
109
+ """
110
+ sql = f"""
111
+ SELECT member_number
112
+ FROM "{self.table_name}"
113
+ WHERE LOWER(first_name) = LOWER(?) AND LOWER(last_name) = LOWER(?)
114
+ """
115
+ self.cursor.execute(sql, (first_name, last_name))
116
+ result = self.cursor.fetchone()
117
+
118
+ if not result and found_error:
119
+ raise ValueError(
120
+ f"❌ Cannot find: {first_name} {last_name} in member database."
121
+ )
122
+
123
+ return result[0] if result else None
124
+
125
+ def get_number(self, full_name: str, found_error: bool = False) -> Optional[int]:
126
+ """
127
+ Find a member number by full name (tries first and last, and then middle last if 3 words).
128
+
129
+ :param full_name: Full name of the member.
130
+ :param found_error: (optional) Raise ValueError if not found.
131
+
132
+ :return: Member number if found, else None.
133
+
134
+ :raises ValueError: If not found and `found_error` is True.
135
+ """
136
+ member_num = None
137
+ try_names = []
138
+ parts = full_name.strip().split()
139
+ # Try first + last
140
+ if len(parts) >= 2:
141
+ first_name = parts[0]
142
+ last_name = parts[-1]
143
+ try_names.append(f"<{first_name} {last_name}>")
144
+ member_num = self.get_number_first_last(first_name, last_name)
145
+
146
+ # Try middle + last if 3 parts and first+last failed
147
+ if not member_num and len(parts) == 3:
148
+ first_name = parts[1]
149
+ last_name = parts[2]
150
+ try_names.append(f"<{first_name} {last_name}>")
151
+ member_num = self.get_number_first_last(first_name, last_name)
152
+
153
+ if not member_num and found_error:
154
+ tried = ", ".join(try_names) if try_names else "No valid names to find"
155
+ raise ValueError(
156
+ f"❌ Cannot find {full_name} in member database. Tried: {tried}"
157
+ )
158
+ return member_num
159
+
160
+ def get_name(self, member_number: int) -> Optional[str]:
161
+ """
162
+ Get full name for a given member number.
163
+
164
+ :param member_number: Member number to look up.
165
+
166
+ :return: Full name as "First Last", or None if not found.
167
+ """
168
+ sql = f"""
169
+ SELECT first_name, last_name
170
+ FROM "{self.table_name}"
171
+ WHERE member_number = ?
172
+ """
173
+ self.cursor.execute(sql, (member_number,))
174
+ result = self.cursor.fetchone()
175
+
176
+ if result:
177
+ first_name, last_name = result
178
+ return f"{first_name} {last_name}"
179
+ return None
180
+
181
+ def _add(self, member: MemberData):
182
+ """
183
+ Insert a member into the database if not already present.
184
+
185
+ :param member: The member to add.
186
+ """
187
+ sql = f"""INSERT OR ABORT INTO "{self.table_name}"
188
+ (member_number, title, first_name, last_name, membermojo_id, short_url)
189
+ VALUES (?, ?, ?, ?, ?, ?)"""
190
+
191
+ try:
192
+ self.cursor.execute(
193
+ sql,
194
+ (
195
+ member.member_num,
196
+ member.title,
197
+ member.first_name,
198
+ member.last_name,
199
+ member.membermojo_id,
200
+ member.short_url,
201
+ ),
202
+ )
203
+ self.conn.commit()
204
+ print(
205
+ f"Created user {member.member_num}: {member.first_name} {member.last_name}"
206
+ )
207
+ except sqlite3.IntegrityError:
208
+ pass
209
+
210
+ def import_csv(self, csv_path: Path):
211
+ """
212
+ Load members from a Membermojo CSV file and insert into the database.
213
+
214
+ :param csv_path: Path to the CSV file.
215
+
216
+ Notes:
217
+ Only adds members not already in the database (INSERT OR ABORT).
218
+ """
219
+ print(f"Using SQLite database version {sqlite3.sqlite_version}")
220
+ self._create_tables()
221
+
222
+ try:
223
+ with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
224
+ mojo_reader = DictReader(csvfile)
225
+
226
+ for row in mojo_reader:
227
+ member = MemberData(
228
+ member_num=int(row["Member number"]),
229
+ title=row["Title"].strip(),
230
+ first_name=row["First name"].strip(),
231
+ last_name=row["Last name"].strip(),
232
+ membermojo_id=int(row["membermojo ID"]),
233
+ short_url=row["Short URL"].strip(),
234
+ )
235
+ self._add(member)
236
+ except FileNotFoundError:
237
+ print(f"CSV file not found: {csv_path}")
@@ -0,0 +1,237 @@
1
+ """
2
+ Module to import and interact with Membermojo completed_payments.csv data in SQLite.
3
+
4
+ Provides automatic column type inference, robust CSV importing, and
5
+ helper methods for querying the database.
6
+ """
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
+
13
+ from .config import CSV_ENCODING # import encoding from config.py
14
+ from .mojo_common import MojoSkel
15
+
16
+
17
+ class Transaction(MojoSkel):
18
+ """
19
+ Handles importing and querying completed payment data.
20
+
21
+ Extends:
22
+ MojoSkel: Base class with transaction database operations.
23
+
24
+ :param payment_db_path: Path to the SQLite database.
25
+ :param table_name: (optional) Name of the table. Defaults to "payments".
26
+ """
27
+
28
+ def __init__(self, payment_db_path: str, table_name: str = "payments"):
29
+ """
30
+ Initialize the Transaction object.
31
+ """
32
+ super().__init__(payment_db_path, table_name)
33
+ self.columns = {}
34
+
35
+ def _guess_type(self, value: any) -> str:
36
+ """
37
+ Guess the SQLite data type of a CSV field value.
38
+
39
+ :param value: The value from a CSV field.
40
+
41
+ :return: One of 'INTEGER', 'REAL', or 'TEXT'.
42
+ """
43
+ if value is None:
44
+ return "TEXT"
45
+
46
+ if isinstance(value, str):
47
+ value = value.strip()
48
+ if value == "":
49
+ return "TEXT"
50
+
51
+ try:
52
+ int(value)
53
+ return "INTEGER"
54
+ except (ValueError, TypeError):
55
+ try:
56
+ float(value)
57
+ return "REAL"
58
+ except (ValueError, TypeError):
59
+ return "TEXT"
60
+
61
+ def _infer_columns_from_rows(self, rows: list[dict]):
62
+ """
63
+ Infer SQLite column types based on sample CSV data.
64
+
65
+ :param rows: Sample rows from CSV to analyze.
66
+ """
67
+ type_counters = defaultdict(Counter)
68
+
69
+ for row in rows:
70
+ for key, value in row.items():
71
+ guessed_type = self._guess_type(value)
72
+ type_counters[key][guessed_type] += 1
73
+
74
+ self.columns = {}
75
+ for col, counter in type_counters.items():
76
+ if counter["TEXT"] == 0:
77
+ if counter["REAL"] > 0:
78
+ self.columns[col] = "REAL"
79
+ else:
80
+ self.columns[col] = "INTEGER"
81
+ else:
82
+ self.columns[col] = "TEXT"
83
+
84
+ print("Inferred columns:", self.columns)
85
+
86
+ def _create_tables(self, table: str, primary_col: str):
87
+ """
88
+ Create the table if it doesn't exist, using inferred schema.
89
+
90
+ :param table: Table name.
91
+ :param primary_col: Column to use as primary key, or None for default.
92
+ """
93
+ col_names = list(self.columns.keys())
94
+ if primary_col is None:
95
+ primary_col = col_names[0]
96
+ elif primary_col not in self.columns:
97
+ raise ValueError(f"Primary key column '{primary_col}' not found in CSV.")
98
+
99
+ cols_def = ", ".join(
100
+ f'"{col}" {col_type}{" PRIMARY KEY" if col == primary_col else ""}'
101
+ for col, col_type in self.columns.items()
102
+ )
103
+
104
+ self.cursor.execute(f'CREATE TABLE IF NOT EXISTS "{table}" ({cols_def})')
105
+ self.conn.commit()
106
+
107
+ def _parse_row_values(self, row: dict, column_types: dict) -> tuple:
108
+ """
109
+ Convert CSV row string values to types suitable for SQLite.
110
+
111
+ :param row: A dictionary from the CSV row.
112
+ :param column_types: Mapping of column names to SQLite types.
113
+
114
+ :return: Parsed values.
115
+ """
116
+ values = []
117
+ for col, col_type in column_types.items():
118
+ val = row.get(col, "")
119
+ if val is None or val.strip() == "":
120
+ values.append(None)
121
+ elif col_type == "REAL":
122
+ values.append(float(val))
123
+ elif col_type == "INTEGER":
124
+ values.append(int(val))
125
+ else:
126
+ values.append(val)
127
+ return tuple(values)
128
+
129
+ def import_csv(self, csv_path: Path, pk_column: str = None, sample_size: int = 100):
130
+ """
131
+ Import a completed_payments.csv file into SQLite.
132
+
133
+ Infers column types and logs failed rows. Creates the table if needed.
134
+
135
+ :param csv_path: Path to the CSV file.
136
+ :param pk_column: (optional) Primary key column name. Defaults to the first column.
137
+ :param sample_size: (optional) Number of rows to sample for type inference. Defaults to 100.
138
+
139
+ :raises ValueError: If the CSV is empty or contains failed insertions.
140
+ """
141
+ try:
142
+ # First pass: infer schema from sample
143
+ with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
144
+ reader = DictReader(csvfile)
145
+ sample_rows = [row for _, row in zip(range(sample_size), reader)]
146
+ if not sample_rows:
147
+ raise ValueError("CSV file is empty.")
148
+
149
+ # Only infer columns if not already set (i.e., first import)
150
+ if not self.columns:
151
+ self._infer_columns_from_rows(sample_rows)
152
+
153
+ except FileNotFoundError:
154
+ print(f"CSV file not found: {csv_path}")
155
+ return
156
+
157
+ # Create table
158
+ self._create_tables(self.table_name, pk_column)
159
+
160
+ # Count rows before import
161
+ count_before = self.count()
162
+
163
+ # Second pass: insert all rows
164
+ failed_rows = []
165
+ with csv_path.open(newline="", encoding=CSV_ENCODING) as csvfile:
166
+ reader = DictReader(csvfile)
167
+ column_list = ", ".join(f'"{col}"' for col in self.columns)
168
+ placeholders = ", ".join(["?"] * len(self.columns))
169
+ insert_stmt = f'INSERT OR IGNORE INTO "{self.table_name}" ({column_list}) \
170
+ VALUES ({placeholders})'
171
+
172
+ for row in reader:
173
+ try:
174
+ self.cursor.execute(
175
+ insert_stmt, self._parse_row_values(row, self.columns)
176
+ )
177
+
178
+ except (
179
+ IntegrityError,
180
+ OperationalError,
181
+ DatabaseError,
182
+ ValueError,
183
+ ) as e:
184
+ failed_rows.append((row.copy(), str(e)))
185
+
186
+ self.conn.commit()
187
+
188
+ print(
189
+ f"Inserted {self.count() - count_before} new rows into '{self.table_name}'."
190
+ )
191
+
192
+ if failed_rows:
193
+ print(f"{len(failed_rows)} rows failed to insert:")
194
+ for row, error in failed_rows[:5]:
195
+ print(f"Failed: {error} | Data: {row}")
196
+ if len(failed_rows) > 5:
197
+ print(f"... and {len(failed_rows) - 5} more")
198
+ raise ValueError(f"Failed to import: {csv_path}")
199
+
200
+ def get_row(self, entry_name: str, entry_value: str) -> dict:
201
+ """
202
+ Retrieve a single row matching column = value (case-insensitive).
203
+
204
+ :param entry_name: Column name to filter by.
205
+ :param entry_value: Value to match.
206
+
207
+ :return: The matching row as a dictionary, or None if not found.
208
+ """
209
+ if not entry_value:
210
+ return None
211
+ query = (
212
+ f'SELECT * FROM "{self.table_name}" WHERE LOWER("{entry_name}") = LOWER(?)'
213
+ )
214
+ self.cursor.execute(query, (entry_value,))
215
+ row = self.cursor.fetchone()
216
+ return dict(row) if row else None
217
+
218
+ def get_row_multi(self, match_dict: dict) -> dict:
219
+ """
220
+ Retrieve the first row matching multiple column = value pairs.
221
+
222
+ :param match_dict: Dictionary of column names and values to match.
223
+
224
+ :return: The first matching row, or None if not found.
225
+ """
226
+ conditions = []
227
+ values = []
228
+ for col, val in match_dict.items():
229
+ if val is None or val == "":
230
+ conditions.append(f'"{col}" IS NULL')
231
+ else:
232
+ conditions.append(f'"{col}" = ?')
233
+ values.append(val)
234
+
235
+ query = f'SELECT * FROM "{self.table_name}" WHERE {" AND ".join(conditions)} LIMIT 1'
236
+ self.cursor.execute(query, values)
237
+ return self.cursor.fetchone()
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: memberjojo
3
+ Version: 1.0
4
+ Summary: memberjojo - tools for working with members.
5
+ Author-email: Duncan Bellamy <dunk@denkimushi.com>
6
+ Description-Content-Type: text/markdown
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: flit ; extra == "dev"
10
+ Requires-Dist: pytest ; extra == "dev"
11
+ Requires-Dist: furo ; extra == "docs"
12
+ Requires-Dist: myst-parser>=2.0 ; extra == "docs"
13
+ Requires-Dist: sphinx>=7.0 ; extra == "docs"
14
+ Requires-Dist: sphinx-autodoc-typehints ; extra == "docs"
15
+ Requires-Dist: black ; extra == "lint"
16
+ Requires-Dist: coverage ; extra == "lint"
17
+ Requires-Dist: pylint ; extra == "lint"
18
+ Project-URL: Home, https://github.com/a16bitsysop/memberjojo
19
+ Provides-Extra: dev
20
+ Provides-Extra: docs
21
+ Provides-Extra: lint
22
+
23
+ # memberjojo
24
+
25
+ `memberjojo` is a Python library for managing [Membermojo](http://membermojo.co.uk/)
26
+ data from CSV imports.\
27
+ It provides member database interactions, and transaction querying.\
28
+ This is done in a local SQLite database, and does not alter anything on Membermojo.\
29
+ It provides tools to load, and query membership and transaction data efficiently
30
+ without having to use SQLite directly.\
31
+ When importing CSV files existing entries are skipped, so you can just import the
32
+ latest download and the local database is updated with new entries.\
33
+ All the transaction data is imported into the database,
34
+ but currently only a limited amount of member data is imported.
35
+
36
+ ---
37
+
38
+ ## Installation
39
+
40
+ Install via `pip`:
41
+
42
+ ```bash
43
+ pip install memberjojo
44
+ ```
45
+
46
+ Or clone the repo and install locally with `flit`:
47
+
48
+ ```bash
49
+ git clone https://github.com/a16bitsysop/memberjojo.git
50
+ cd memberjojo
51
+ flit install --symlink
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ Example loading members and using Member objects:
57
+
58
+ ```python
59
+ from pathlib import Path
60
+ from membermojo import Member
61
+
62
+ # database is created if it does not exist
63
+ member_database_path = Path(Path(__file__).parent, "database", "my-members.db")
64
+ member_csv_path = Path("download", "members.csv")
65
+
66
+ members = Member(member_database_path)
67
+ members.import_csv(member_csv_path)
68
+
69
+ for member in members:
70
+ print(member.first_name, member.last_name, member.member_num)
71
+
72
+ # Get full name for a given member number
73
+ found_name = members.get_name(1)
74
+ if found_name:
75
+ print(f"Member with id of 1 is {found_name}")
76
+ else:
77
+ print("Member 1 does not exist")
78
+ ```
79
+
80
+ ## Documentation
81
+
82
+ Full documentation is available at
83
+ 👉 [https://a16bitsysop.github.io/memberjojo/](https://a16bitsysop.github.io/memberjojo/)
84
+
85
+ ---
86
+
87
+ ## Running Tests
88
+
89
+ Run tests:
90
+
91
+ ```bash
92
+ pytest
93
+ ```
94
+
95
+ ## Contributing
96
+
97
+ Contributions are welcome! Please:
98
+
99
+ 1. Fork the repo
100
+ 2. Create your feature branch `git checkout -b my-feature`
101
+ 3. Edit the source code to add and test your changes
102
+ 4. Commit your changes `git commit -m 'Add some feature'`
103
+ 5. Push to your branch `git push origin my-feature`
104
+ 6. Open a Pull Request
105
+
106
+ Please follow the existing code style and write tests for new features.
107
+
108
+ ---
109
+
110
+ ## License
111
+
112
+ This project is licensed under the MIT [MIT License](https://github.com/a16bitsysop/memberjojo/blob/main/LICENSE).
113
+
114
+ ---
115
+
116
+ ## Contact
117
+
118
+ Created and maintained by Duncan Bellamy.
119
+ Feel free to open issues or reach out on GitHub.
120
+
121
+ ---
122
+
@@ -0,0 +1,10 @@
1
+ memberjojo/__init__.py,sha256=KfW3YTaJkEfdjr1Yf9Lf1jBrBErvCbZ7p-1Y6qzEIOY,303
2
+ memberjojo/_version.py,sha256=ctuj3jjBGFJ45_qfblXgtZZthejCRhGtV2w4SJZU4W8,506
3
+ memberjojo/config.py,sha256=_R3uWh5bfMCfZatXLm49pDXet-tipoPbUO7FUeMu2OI,73
4
+ memberjojo/mojo_common.py,sha256=hSMNggC1BOTLOeRQlv4LOC7gqfM0GMRdoHHT5PnuakA,1597
5
+ memberjojo/mojo_member.py,sha256=IuZMvERjmrBoz_rQIr9SBIDEC0PqEPiJX2SvA69paSU,8018
6
+ memberjojo/mojo_transaction.py,sha256=rtyRfRPexFlnC9LFdAQe0yy56AaXeunyF6tSobG-Ipo,8203
7
+ memberjojo-1.0.dist-info/licenses/LICENSE,sha256=eaTLEca5OoRQ9r8GlC6Rwa1BormM3q-ppDWLFFxhQxI,1071
8
+ memberjojo-1.0.dist-info/WHEEL,sha256=Dyt6SBfaasWElUrURkknVFAZDHSTwxg3PaTza7RSbkY,100
9
+ memberjojo-1.0.dist-info/METADATA,sha256=zv0RsUgqtpxKkJdJizitpXiFpt4-a7nrlOM5VBm9peQ,3155
10
+ memberjojo-1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: flit 3.12.0
3
+ Root-Is-Purelib: true
4
+ Tag: py2-none-any
5
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Duncan Bellamy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.