memberjojo 1.1__tar.gz → 2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memberjojo-1.1 → memberjojo-2.0}/PKG-INFO +24 -10
- {memberjojo-1.1 → memberjojo-2.0}/README.md +22 -9
- {memberjojo-1.1 → memberjojo-2.0}/pyproject.toml +2 -2
- {memberjojo-1.1 → memberjojo-2.0}/src/memberjojo/__init__.py +1 -1
- {memberjojo-1.1 → memberjojo-2.0}/src/memberjojo/_version.py +16 -3
- memberjojo-2.0/src/memberjojo/mojo_common.py +227 -0
- memberjojo-2.0/src/memberjojo/mojo_loader.py +140 -0
- memberjojo-2.0/src/memberjojo/mojo_member.py +237 -0
- memberjojo-2.0/src/memberjojo/mojo_transaction.py +32 -0
- {memberjojo-1.1 → memberjojo-2.0}/tests/test_mojo_members.py +39 -19
- {memberjojo-1.1 → memberjojo-2.0}/tests/test_mojo_members_iter.py +4 -4
- memberjojo-2.0/tests/test_mojo_transactions.py +139 -0
- memberjojo-1.1/src/memberjojo/mojo_common.py +0 -55
- memberjojo-1.1/src/memberjojo/mojo_member.py +0 -237
- memberjojo-1.1/src/memberjojo/mojo_transaction.py +0 -237
- memberjojo-1.1/tests/test_mojo_transactions.py +0 -240
- {memberjojo-1.1 → memberjojo-2.0}/LICENSE +0 -0
- {memberjojo-1.1 → memberjojo-2.0}/src/memberjojo/config.py +0 -0
- {memberjojo-1.1 → memberjojo-2.0}/tests/test_version.py +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: memberjojo
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0
|
|
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)
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
# memberjojo
|
|
2
2
|
|
|
3
|
-
`memberjojo` is a Python library for
|
|
3
|
+
`memberjojo` is a Python library for using [Membermojo](http://membermojo.co.uk/)
|
|
4
4
|
data from CSV imports.\
|
|
5
|
-
It provides member database
|
|
6
|
-
This is done in a local SQLite database, and does not alter
|
|
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
|
|
10
|
-
latest download and the local database is updated
|
|
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.
|
|
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]
|
|
@@ -39,10 +40,9 @@ include = ["tests", "src/memberjojo/_version.py"]
|
|
|
39
40
|
write_to = "src/memberjojo/_version.py"
|
|
40
41
|
|
|
41
42
|
[tool.pytest.ini_options]
|
|
42
|
-
pythonpath = [
|
|
43
|
+
pythonpath = ["src"]
|
|
43
44
|
|
|
44
45
|
[tool.pylint.main]
|
|
45
46
|
output-format = "colorized"
|
|
46
47
|
verbose = true
|
|
47
|
-
ignore = ["docs"]
|
|
48
48
|
ignore-patterns = ["_version.py"]
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
# file generated by setuptools-scm
|
|
2
2
|
# don't change, don't track in version control
|
|
3
3
|
|
|
4
|
-
__all__ = [
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
5
12
|
|
|
6
13
|
TYPE_CHECKING = False
|
|
7
14
|
if TYPE_CHECKING:
|
|
@@ -9,13 +16,19 @@ if TYPE_CHECKING:
|
|
|
9
16
|
from typing import Union
|
|
10
17
|
|
|
11
18
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
12
20
|
else:
|
|
13
21
|
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
14
23
|
|
|
15
24
|
version: str
|
|
16
25
|
__version__: str
|
|
17
26
|
__version_tuple__: VERSION_TUPLE
|
|
18
27
|
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
19
30
|
|
|
20
|
-
__version__ = version = '
|
|
21
|
-
__version_tuple__ = version_tuple = (
|
|
31
|
+
__version__ = version = '2.0'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 0)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = 'g9cd84a0d7'
|
|
@@ -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()
|