memberjojo 1.0__tar.gz → 1.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,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memberjojo
3
- Version: 1.0
3
+ Version: 1.1
4
4
  Summary: memberjojo - tools for working with members.
5
5
  Author-email: Duncan Bellamy <dunk@denkimushi.com>
6
+ Requires-Python: >=3.8
6
7
  Description-Content-Type: text/markdown
7
8
  License-Expression: MIT
8
9
  License-File: LICENSE
10
+ Requires-Dist: coverage ; extra == "dev"
9
11
  Requires-Dist: flit ; extra == "dev"
10
12
  Requires-Dist: pytest ; extra == "dev"
11
13
  Requires-Dist: furo ; extra == "docs"
@@ -13,7 +15,6 @@ Requires-Dist: myst-parser>=2.0 ; extra == "docs"
13
15
  Requires-Dist: sphinx>=7.0 ; extra == "docs"
14
16
  Requires-Dist: sphinx-autodoc-typehints ; extra == "docs"
15
17
  Requires-Dist: black ; extra == "lint"
16
- Requires-Dist: coverage ; extra == "lint"
17
18
  Requires-Dist: pylint ; extra == "lint"
18
19
  Project-URL: Home, https://github.com/a16bitsysop/memberjojo
19
20
  Provides-Extra: dev
@@ -9,18 +9,19 @@ readme = "README.md"
9
9
  license = "MIT"
10
10
  license-files = ["LICENSE"]
11
11
  dynamic = ["version", "description"]
12
+ requires-python = ">=3.8"
12
13
 
13
14
  [project.urls]
14
15
  Home = "https://github.com/a16bitsysop/memberjojo"
15
16
 
16
17
  [project.optional-dependencies]
17
18
  dev = [
19
+ "coverage",
18
20
  "flit",
19
21
  "pytest",
20
22
  ]
21
23
  lint = [
22
24
  "black",
23
- "coverage",
24
25
  "pylint",
25
26
  ]
26
27
  docs = [
@@ -31,7 +32,8 @@ docs = [
31
32
  ]
32
33
 
33
34
  [tool.flit.sdist]
34
- exclude = [".gitignore"]
35
+ exclude = ["**/.DS_Store"]
36
+ include = ["tests", "src/memberjojo/_version.py"]
35
37
 
36
38
  [tool.setuptools_scm]
37
39
  write_to = "src/memberjojo/_version.py"
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '1.0'
21
- __version_tuple__ = version_tuple = (1, 0)
20
+ __version__ = version = '1.1'
21
+ __version_tuple__ = version_tuple = (1, 1)
@@ -0,0 +1,185 @@
1
+ """
2
+ Tests for the member module
3
+ """
4
+
5
+ from csv import DictWriter
6
+ from pathlib import Path
7
+ from tempfile import NamedTemporaryFile
8
+
9
+ import pytest
10
+ from memberjojo import Member
11
+
12
+ # pylint: disable=redefined-outer-name
13
+ # or pylint thinks fixtures are redined as function variables
14
+ # --- Fixtures & Helpers ---
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_csv_file(tmp_path):
19
+ """
20
+ Create a temporary mock CSV file for testing.
21
+ Returns path to the CSV.
22
+ """
23
+ fieldnames = [
24
+ "Member number",
25
+ "Title",
26
+ "First name",
27
+ "Last name",
28
+ "membermojo ID",
29
+ "Short URL",
30
+ ]
31
+ rows = [
32
+ {
33
+ "Member number": "1",
34
+ "Title": "Mr",
35
+ "First name": "John",
36
+ "Last name": "Doe",
37
+ "membermojo ID": "1001",
38
+ "Short URL": "http://short.url/johndoe",
39
+ },
40
+ {
41
+ "Member number": "2",
42
+ "Title": "Ms",
43
+ "First name": "Jane",
44
+ "Last name": "Smith",
45
+ "membermojo ID": "1002",
46
+ "Short URL": "http://short.url/janesmith",
47
+ },
48
+ {
49
+ "Member number": "3",
50
+ "Title": "Dr",
51
+ "First name": "Emily",
52
+ "Last name": "Stone",
53
+ "membermojo ID": "1001",
54
+ "Short URL": "http://short.url/emilystone",
55
+ }, # duplicate ID
56
+ {
57
+ "Member number": "1",
58
+ "Title": "Mrs",
59
+ "First name": "Sara",
60
+ "Last name": "Connor",
61
+ "membermojo ID": "1003",
62
+ "Short URL": "http://short.url/saraconnor",
63
+ }, # duplicate number
64
+ {
65
+ "Member number": "4",
66
+ "Title": "Sir",
67
+ "First name": "Rick",
68
+ "Last name": "Grimes",
69
+ "membermojo ID": "1004",
70
+ "Short URL": "http://short.url/rickgrimes",
71
+ }, # invalid title
72
+ ]
73
+
74
+ csv_path = tmp_path / "mock_data.csv"
75
+ with csv_path.open(mode="w", encoding="ISO-8859-1", newline="") as f:
76
+ writer = DictWriter(f, fieldnames=fieldnames)
77
+ writer.writeheader()
78
+ writer.writerows(rows)
79
+
80
+ return Path(csv_path)
81
+
82
+
83
+ @pytest.fixture
84
+ def db_path():
85
+ """
86
+ Temp file for db connection
87
+ """
88
+ with NamedTemporaryFile(suffix=".db", delete=False) as tmp:
89
+ path = Path(tmp.name)
90
+ yield path
91
+ path.unlink()
92
+
93
+
94
+ @pytest.fixture
95
+ def member_db(db_path, mock_csv_file):
96
+ """
97
+ Test sqlite member database
98
+ """
99
+ test_db = Member(db_path)
100
+ test_db.import_csv(mock_csv_file)
101
+ return test_db
102
+
103
+
104
+ # --- Tests ---
105
+
106
+
107
+ def test_empty_db(capsys):
108
+ """
109
+ Test empty db
110
+ """
111
+ with NamedTemporaryFile(suffix=".db") as tmp:
112
+ empty_db = Member(tmp.name)
113
+ # create tables so is empty database
114
+ empty_db._create_tables() # pylint: disable=protected-access
115
+ empty_db.show_table()
116
+ captured = capsys.readouterr()
117
+ assert "(No data)" in captured.out
118
+ assert empty_db.count() == 0
119
+
120
+
121
+ def test_invalid_csv_path_message(tmp_path, db_path, capsys):
122
+ """
123
+ Test import non existing csv file
124
+ """
125
+ non_exist = Path(tmp_path, "non-exist.csv")
126
+ txn = Member(db_path)
127
+ txn.import_csv(non_exist)
128
+ # Capture stdout
129
+ captured = capsys.readouterr()
130
+ assert "CSV file not found" in captured.out
131
+ assert str(non_exist) in captured.out
132
+
133
+
134
+ def test_member_import_and_validation(member_db):
135
+ """
136
+ Test importing valid/invalid members from CSV.
137
+ """
138
+ # Valid inserts
139
+ assert member_db.get_number_first_last("john", "doe") == 1
140
+ assert member_db.get_number("Jane Smith") == 2
141
+ assert member_db.get_name(2) == "Jane Smith"
142
+ # Invalid member number
143
+ assert member_db.get_name(888) is None
144
+
145
+ # Should not be inserted due to duplicate membermojo ID
146
+ assert member_db.get_number_first_last("Emily", "Stone") is None
147
+
148
+ # Should not be inserted due to duplicate member_number
149
+ assert member_db.get_number("Sara Connor") is None
150
+
151
+ # Should not be inserted due to invalid title
152
+ assert member_db.get_number_first_last("Rick", "Grimes") is None
153
+
154
+
155
+ def test_show_table(member_db):
156
+ """
157
+ Test the show table function
158
+ """
159
+ # Should be equal as default show_table is 5 entries and member_db is 2
160
+ entries = member_db.count()
161
+ assert entries == 2
162
+ assert member_db.show_table() == member_db.show_table(100)
163
+ assert member_db.show_table(entries) == member_db.show_table(100)
164
+
165
+
166
+ def test_get_number_first_last_not_found_raises(member_db):
167
+ """
168
+ Test found_error
169
+ """
170
+ with pytest.raises(
171
+ ValueError, match=r"❌ Cannot find: John Snow in member database."
172
+ ):
173
+ member_db.get_number_first_last("John", "Snow", found_error=True)
174
+
175
+
176
+ def test_get_number_first_last_more_names(member_db):
177
+ """
178
+ Test logic for 3 names passed
179
+ """
180
+ assert member_db.get_number("Dr Jane Smith") == 2
181
+ assert member_db.get_number("John Jojo Doe") == 1
182
+ with pytest.raises(ValueError) as exc_info:
183
+ member_db.get_number("Emily Sara", found_error=True)
184
+
185
+ assert "Cannot find" in str(exc_info.value)
@@ -0,0 +1,66 @@
1
+ """
2
+ Iteration tests for the Member class
3
+ """
4
+
5
+ import csv
6
+ from memberjojo import Member, MemberData
7
+
8
+
9
+ def test_member_iter(tmp_path):
10
+ """
11
+ Test for iterating over the member data.
12
+ """
13
+ # Prepare sample CSV data
14
+ sample_csv = tmp_path / "members.csv"
15
+ sample_data = [
16
+ {
17
+ "Member number": "11",
18
+ "Title": "Mr",
19
+ "First name": "Johnny",
20
+ "Last name": "Doe",
21
+ "membermojo ID": "8001",
22
+ "Short URL": "http://short.url/johnny",
23
+ },
24
+ {
25
+ "Member number": "12",
26
+ "Title": "Ms",
27
+ "First name": "Janice",
28
+ "Last name": "Smith",
29
+ "membermojo ID": "8002",
30
+ "Short URL": "http://short.url/janice",
31
+ },
32
+ ]
33
+
34
+ # Write CSV file
35
+ with sample_csv.open("w", newline="", encoding="utf-8") as f:
36
+ writer = csv.DictWriter(f, fieldnames=sample_data[0].keys())
37
+ writer.writeheader()
38
+ writer.writerows(sample_data)
39
+
40
+ # Create DB path
41
+ db_path = tmp_path / "test_members.db"
42
+
43
+ # Instantiate Member and import CSV
44
+ members = Member(db_path)
45
+ members.import_csv(sample_csv)
46
+
47
+ # Collect members from iterator
48
+ iterated_members = list(members)
49
+
50
+ # Check that iteration yields correct number of members
51
+ assert len(iterated_members) == len(sample_data)
52
+
53
+ # Check that fields match for first member
54
+ first = iterated_members[0]
55
+ assert isinstance(first, MemberData)
56
+ assert first.member_num == int(sample_data[0]["Member number"])
57
+ assert first.title == sample_data[0]["Title"]
58
+ assert first.first_name == sample_data[0]["First name"]
59
+ assert first.last_name == sample_data[0]["Last name"]
60
+ assert first.membermojo_id == int(sample_data[0]["membermojo ID"])
61
+ assert first.short_url == sample_data[0]["Short URL"]
62
+
63
+ # Check second member also matches
64
+ second = iterated_members[1]
65
+ assert second.first_name == sample_data[1]["First name"]
66
+ assert second.last_name == sample_data[1]["Last name"]
@@ -0,0 +1,240 @@
1
+ """
2
+ Tests for the transaction module
3
+ """
4
+
5
+ import tempfile
6
+ import csv
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from memberjojo import Transaction # Update with your actual module name
11
+
12
+ # pylint: disable=redefined-outer-name
13
+ # or pylint thinks fixtures are redined as function variables
14
+ # --- Fixtures & Helpers ---
15
+
16
+
17
+ @pytest.fixture
18
+ def csv_file(tmp_path):
19
+ """
20
+ Temp csv file for testing
21
+ """
22
+ path = tmp_path / "test_data.csv"
23
+ data = [
24
+ {"id": "1", "amount": "100.5", "desc": "Deposit"},
25
+ {"id": "2", "amount": "200", "desc": "Withdrawal"},
26
+ {"id": "3", "amount": "150", "desc": "Refund"},
27
+ {"id": "4", "amount": "175", "desc": None},
28
+ {"id": "5", "amount": "345", "desc": ""},
29
+ ]
30
+ with path.open("w", newline="") as f:
31
+ writer = csv.DictWriter(f, fieldnames=["id", "amount", "desc"])
32
+ writer.writeheader()
33
+ writer.writerows(data)
34
+ return Path(path)
35
+
36
+
37
+ @pytest.fixture
38
+ def db_path():
39
+ """
40
+ Temp file for db connection
41
+ """
42
+ with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
43
+ path = Path(tmp.name)
44
+ yield path
45
+ path.unlink()
46
+
47
+
48
+ @pytest.fixture
49
+ def payment_db(db_path, csv_file):
50
+ """
51
+ Test sqlite transaction database
52
+ """
53
+ test_db = Transaction(db_path)
54
+ test_db.import_csv(csv_file, pk_column="id")
55
+ return test_db
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "input_value, expected",
60
+ [
61
+ (None, "TEXT"),
62
+ ("", "TEXT"),
63
+ ("abc", "TEXT"),
64
+ ("123", "INTEGER"),
65
+ ("123.45", "REAL"),
66
+ (" 42 ", "INTEGER"), # whitespace-trimmed input
67
+ ],
68
+ )
69
+
70
+
71
+ # --- Tests ---
72
+
73
+
74
+ def test_guess_type_various(input_value, expected):
75
+ """
76
+ Test all the code paths in _guess_type
77
+ """
78
+ txn = Transaction(":memory:")
79
+ assert txn._guess_type(input_value) == expected # pylint: disable=protected-access
80
+
81
+
82
+ def test_empty_csv_import(tmp_path, db_path):
83
+ """
84
+ Test importing empty and just header csv
85
+ """
86
+ txn = Transaction(db_path)
87
+ empty_csv = tmp_path / "empty.csv"
88
+ empty_csv.write_text("", encoding="utf-8") # Fully empty
89
+
90
+ assert empty_csv.exists()
91
+ assert empty_csv.stat().st_size == 0
92
+ with pytest.raises(ValueError, match="CSV file is empty."):
93
+ txn.import_csv(empty_csv)
94
+
95
+ # OR with only headers
96
+ empty_csv.write_text("id,amount,desc\n", encoding="utf-8")
97
+
98
+ # Use it in your import
99
+ with pytest.raises(ValueError, match="CSV file is empty."):
100
+ txn.import_csv(empty_csv)
101
+
102
+
103
+ def test_create_table_with_default_pk(csv_file, db_path):
104
+ """
105
+ Test _create_tables uses first column as primary key when pk_column is None.
106
+ """
107
+ txn = Transaction(db_path, table_name="auto_pk_table")
108
+ txn.import_csv(Path(csv_file)) # No pk_column provided
109
+
110
+ # Validate schema — first column should be the primary key
111
+ txn.cursor.execute("PRAGMA table_info(auto_pk_table)")
112
+ schema = txn.cursor.fetchall()
113
+ pk_cols = [col["name"] for col in schema if col["pk"] == 1]
114
+
115
+ assert pk_cols == ["id"]
116
+
117
+
118
+ def test_invalid_csv_path_message(tmp_path, db_path, capsys):
119
+ """
120
+ Test import non existing csv file
121
+ """
122
+ non_exist = Path(tmp_path, "non-exist.csv")
123
+ txn = Transaction(db_path)
124
+ txn.import_csv(non_exist)
125
+ # Capture stdout
126
+ captured = capsys.readouterr()
127
+ assert "CSV file not found" in captured.out
128
+ assert str(non_exist) in captured.out
129
+
130
+
131
+ def test_type_mismatch_in_second_import_raises(tmp_path, db_path):
132
+ """
133
+ Import valid CSV first. Then try a second CSV with invalid type, and ensure it raises.
134
+ """
135
+ txn = Transaction(db_path, table_name="payments")
136
+
137
+ # First CSV: valid
138
+ valid_csv = tmp_path / "valid.csv"
139
+ valid_csv.write_text(
140
+ ("id,amount,desc\n" + "1,100.0,Deposit\n" + "2,200.0,Withdrawal\n"),
141
+ encoding="utf-8",
142
+ )
143
+ txn.import_csv(valid_csv, pk_column="id")
144
+
145
+ # Second CSV: invalid amount type
146
+ invalid_csv = tmp_path / "invalid.csv"
147
+ invalid_csv.write_text(
148
+ (
149
+ "id,amount,desc\n"
150
+ + "3,not_a_number,Invalid Amount\n"
151
+ + "6,not_a_number,Invalid Amount\n"
152
+ + "7,not_a_number1,Invalid Amount\n"
153
+ + "8,not_a_number2,Invalid Amount\n"
154
+ + "9,not_a_number3,Invalid Amount\n"
155
+ + "10,not_a_number4,Invalid Amount\n"
156
+ ),
157
+ encoding="utf-8",
158
+ )
159
+
160
+ # Reuse the same txn instance so the schema remains
161
+ with pytest.raises(ValueError, match="Failed to import:"):
162
+ txn.import_csv(invalid_csv, pk_column="id")
163
+
164
+ # Ensure the invalid row was not inserted
165
+ assert txn.count() == 2
166
+
167
+
168
+ def test_import_with_custom_primary_key(csv_file, db_path):
169
+ """
170
+ test_import_with_custom_primary_key
171
+ """
172
+ txn = Transaction(db_path, table_name="transactions")
173
+
174
+ # Import using 'amount' as the PK
175
+ txn.import_csv(Path(csv_file), pk_column="amount")
176
+
177
+ # Check that 'amount' is used as primary key
178
+ txn.cursor.execute("PRAGMA table_info(transactions)")
179
+ schema = txn.cursor.fetchall()
180
+ pk_columns = [col["name"] for col in schema if col["pk"] == 1]
181
+ assert pk_columns == ["amount"], "Expected 'amount' to be primary key"
182
+
183
+ # Check row count
184
+ assert txn.count() == 5
185
+
186
+ # Retrieve row by primary key
187
+ row = txn.get_row("amount", 200.0)
188
+ assert row is not None
189
+ assert row["desc"] == "Withdrawal"
190
+ assert row["id"] == 2
191
+ # Test null entry_value
192
+ assert txn.get_row("amount", "") is None
193
+
194
+
195
+ def test_import_missing_primary_key(csv_file, db_path):
196
+ """
197
+ test_import_with_custom_primary_key
198
+ """
199
+ txn = Transaction(db_path, table_name="transactions")
200
+
201
+ with pytest.raises(
202
+ ValueError, match="Primary key column 'none_id' not found in CSV."
203
+ ):
204
+ # Import using 'none_id' as the PK
205
+ txn.import_csv(Path(csv_file), pk_column="none_id")
206
+
207
+
208
+ def test_duplicate_primary_key_ignored(payment_db, csv_file):
209
+ """
210
+ test_duplicate_primary_key_ignored
211
+ """
212
+
213
+ # Re-import same CSV — should ignore duplicates due to OR IGNORE
214
+ payment_db.import_csv(Path(csv_file), pk_column="id")
215
+
216
+ assert payment_db.count() == 5 # No duplicates added
217
+
218
+
219
+ def test_get_row_multi(payment_db):
220
+ """
221
+ Test retrieving a row using multiple column conditions
222
+ """
223
+
224
+ # Exact match for id=2 and desc='Withdrawal'
225
+ row = payment_db.get_row_multi({"id": "2", "desc": "Withdrawal"})
226
+ assert row is not None
227
+ assert row["id"] == 2
228
+ assert row["desc"] == "Withdrawal"
229
+ assert row["amount"] == 200.0
230
+
231
+ # Match with numeric and empty string
232
+ row = payment_db.get_row_multi({"id": "5", "desc": ""})
233
+ assert row is not None
234
+ assert row["id"] == 5
235
+ assert row["desc"] is None
236
+ assert row["amount"] == 345.0
237
+
238
+ # No match
239
+ row = payment_db.get_row_multi({"id": "3", "desc": "Not a match"})
240
+ assert row is None
@@ -0,0 +1,27 @@
1
+ """
2
+ Test for _version.py not present
3
+ """
4
+
5
+ import importlib
6
+ import sys
7
+ from unittest import mock
8
+
9
+
10
+ def test_version_fallback():
11
+ """
12
+ Remove _version to test for missing _version.py
13
+ """
14
+
15
+ # Remove the _version module from sys.modules if present
16
+ sys.modules.pop("memberjojo._version", None)
17
+
18
+ # Patch sys.modules so importing memberjojo._version raises ImportError
19
+ with mock.patch.dict("sys.modules", {"memberjojo._version": None}):
20
+ # Remove the main package module so reload triggers fresh import logic
21
+ sys.modules.pop("memberjojo", None)
22
+
23
+ import memberjojo # pylint: disable=import-outside-toplevel
24
+
25
+ importlib.reload(memberjojo)
26
+
27
+ assert memberjojo.__version__ == "0.0.0+local"
File without changes
File without changes