excel-dbapi 0.1.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.
- excel_dbapi-0.1.0/.gitignore +91 -0
- excel_dbapi-0.1.0/LICENSE +21 -0
- excel_dbapi-0.1.0/Makefile +43 -0
- excel_dbapi-0.1.0/PKG-INFO +20 -0
- excel_dbapi-0.1.0/README.md +94 -0
- excel_dbapi-0.1.0/pyproject.toml +32 -0
- excel_dbapi-0.1.0/src/excel_dbapi/__init__.py +1 -0
- excel_dbapi-0.1.0/src/excel_dbapi/connection.py +75 -0
- excel_dbapi-0.1.0/src/excel_dbapi/cursor.py +75 -0
- excel_dbapi-0.1.0/src/excel_dbapi/engines/__init__.py +0 -0
- excel_dbapi-0.1.0/src/excel_dbapi/engines/openpyxl_engine.py +46 -0
- excel_dbapi-0.1.0/src/excel_dbapi/exceptions.py +34 -0
- excel_dbapi-0.1.0/src/excel_dbapi/query.py +54 -0
- excel_dbapi-0.1.0/src/excel_dbapi/remote.py +17 -0
- excel_dbapi-0.1.0/src/excel_dbapi/table.py +53 -0
- excel_dbapi-0.1.0/tests/test_connection.py +24 -0
- excel_dbapi-0.1.0/tests/test_cursor.py +54 -0
- excel_dbapi-0.1.0/tests/test_query.py +72 -0
- excel_dbapi-0.1.0/tests/test_table.py +51 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Virtual environment
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
env/
|
|
10
|
+
ENV/
|
|
11
|
+
|
|
12
|
+
# Python cache
|
|
13
|
+
*.log
|
|
14
|
+
*.pot
|
|
15
|
+
*.pyc
|
|
16
|
+
*.pyo
|
|
17
|
+
*.pyd
|
|
18
|
+
*.db
|
|
19
|
+
*.sqlite3
|
|
20
|
+
|
|
21
|
+
# Distribution / packaging
|
|
22
|
+
.Python
|
|
23
|
+
build/
|
|
24
|
+
develop-eggs/
|
|
25
|
+
dist/
|
|
26
|
+
downloads/
|
|
27
|
+
eggs/
|
|
28
|
+
.eggs/
|
|
29
|
+
lib/
|
|
30
|
+
lib64/
|
|
31
|
+
parts/
|
|
32
|
+
sdist/
|
|
33
|
+
var/
|
|
34
|
+
*.egg-info/
|
|
35
|
+
.installed.cfg
|
|
36
|
+
*.egg
|
|
37
|
+
|
|
38
|
+
# Hatch build artifacts
|
|
39
|
+
*.hatch
|
|
40
|
+
|
|
41
|
+
# Installer logs
|
|
42
|
+
pip-log.txt
|
|
43
|
+
pip-delete-this-directory.txt
|
|
44
|
+
|
|
45
|
+
# Test / coverage reports
|
|
46
|
+
htmlcov/
|
|
47
|
+
.tox/
|
|
48
|
+
.nox/
|
|
49
|
+
.coverage
|
|
50
|
+
.coverage.*
|
|
51
|
+
.cache
|
|
52
|
+
nosetests.xml
|
|
53
|
+
coverage.xml
|
|
54
|
+
*.cover
|
|
55
|
+
*.py,cover
|
|
56
|
+
.hypothesis/
|
|
57
|
+
.pytest_cache/
|
|
58
|
+
|
|
59
|
+
# Lint & type checking
|
|
60
|
+
.mypy_cache/
|
|
61
|
+
.dmypy.json
|
|
62
|
+
.pyre/
|
|
63
|
+
ruff_cache/
|
|
64
|
+
|
|
65
|
+
# pre-commit
|
|
66
|
+
.pre-commit-config.yaml
|
|
67
|
+
.pre-commit-config.yaml.lock
|
|
68
|
+
.pre-commit-hooks.yaml
|
|
69
|
+
.pre-commit-hooks.yaml.lock
|
|
70
|
+
|
|
71
|
+
# dotenv
|
|
72
|
+
.env
|
|
73
|
+
.env.*
|
|
74
|
+
*.env
|
|
75
|
+
|
|
76
|
+
# IDEs & editors
|
|
77
|
+
.vscode/
|
|
78
|
+
.idea/
|
|
79
|
+
*.sublime-project
|
|
80
|
+
*.sublime-workspace
|
|
81
|
+
|
|
82
|
+
# Mac OS
|
|
83
|
+
.DS_Store
|
|
84
|
+
|
|
85
|
+
# Windows
|
|
86
|
+
Thumbs.db
|
|
87
|
+
ehthumbs.db
|
|
88
|
+
desktop.ini
|
|
89
|
+
|
|
90
|
+
# Poetry
|
|
91
|
+
poetry.lock
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Yeongseon Choe
|
|
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.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.PHONY: install format lint type-check sec-check dead-code test build clean
|
|
2
|
+
|
|
3
|
+
.PHONY: install-dev install-prod
|
|
4
|
+
|
|
5
|
+
install-dev:
|
|
6
|
+
pip install .[dev]
|
|
7
|
+
hatch develop
|
|
8
|
+
|
|
9
|
+
install-prod:
|
|
10
|
+
pip install .
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
format:
|
|
14
|
+
black .
|
|
15
|
+
isort .
|
|
16
|
+
|
|
17
|
+
lint:
|
|
18
|
+
ruff check .
|
|
19
|
+
mypy .
|
|
20
|
+
|
|
21
|
+
type-check:
|
|
22
|
+
mypy .
|
|
23
|
+
|
|
24
|
+
sec-check:
|
|
25
|
+
bandit -r src
|
|
26
|
+
|
|
27
|
+
dead-code:
|
|
28
|
+
vulture src
|
|
29
|
+
|
|
30
|
+
test:
|
|
31
|
+
pytest
|
|
32
|
+
|
|
33
|
+
build:
|
|
34
|
+
python -m build
|
|
35
|
+
|
|
36
|
+
clean:
|
|
37
|
+
rm -rf dist build *.egg-info
|
|
38
|
+
|
|
39
|
+
publish-test:
|
|
40
|
+
twine upload --repository testpypi dist/*
|
|
41
|
+
|
|
42
|
+
publish:
|
|
43
|
+
twine upload dist/*
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: excel-dbapi
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A DBAPI-like interface for Excel files with extensible engines
|
|
5
|
+
Author-email: Yeongseon Choe <yeongseon.choe@gmail.com>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: openpyxl>=3.1.0
|
|
9
|
+
Requires-Dist: requests>=2.28.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: bandit<2.0.0,>=1.7.0; extra == 'dev'
|
|
12
|
+
Requires-Dist: black<26.0.0,>=25.1.0; extra == 'dev'
|
|
13
|
+
Requires-Dist: build; extra == 'dev'
|
|
14
|
+
Requires-Dist: isort<7.0.0,>=6.0.1; extra == 'dev'
|
|
15
|
+
Requires-Dist: mypy<2.0.0,>=1.15.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pre-commit; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest<9.0.0,>=8.3.5; extra == 'dev'
|
|
18
|
+
Requires-Dist: ruff<0.12.0,>=0.11.2; extra == 'dev'
|
|
19
|
+
Requires-Dist: types-requests<3.0.0,>=2.28.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: vulture<3.0,>=2.0; extra == 'dev'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# excel-dbapi
|
|
2
|
+
|
|
3
|
+
A DBAPI-like interface for Excel files with extensible engines.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Development Setup
|
|
8
|
+
|
|
9
|
+
If you're contributing to this project or running it in development mode, follow the steps below.
|
|
10
|
+
|
|
11
|
+
### 1. Clone the repository
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
git clone https://github.com/your-username/exceldb.git
|
|
15
|
+
cd exceldb
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### 2. Create and activate virtual environment
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python -m venv .venv
|
|
22
|
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 3. Install dependencies (with dev tools)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install --upgrade pip setuptools hatchling
|
|
29
|
+
pip install .[dev]
|
|
30
|
+
hatch develop
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
> 💡 This installs runtime and development dependencies (e.g. `pytest`, `black`, `mypy`, `ruff`, etc.)
|
|
34
|
+
> and sets up the project in editable mode using Hatch.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
### 4. Run tests, linters, and formatters
|
|
39
|
+
|
|
40
|
+
You can use the provided `Makefile` for convenient commands:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
make test # Run all tests
|
|
44
|
+
make format # Auto-format code (black + isort)
|
|
45
|
+
make lint # Run static analysis (ruff + mypy)
|
|
46
|
+
make sec-check # Run security check (bandit)
|
|
47
|
+
make dead-code # Detect unused code (vulture)
|
|
48
|
+
make build # Build the package
|
|
49
|
+
make clean # Remove build artifacts
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
### ✅ 5. Pre-commit setup
|
|
55
|
+
|
|
56
|
+
We use `pre-commit` to ensure consistent code quality before every commit.
|
|
57
|
+
|
|
58
|
+
#### Install and activate hooks
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pre-commit install
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
#### Run checks on all files manually (first time)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pre-commit run --all-files
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### Pre-commit will automatically run the following tools:
|
|
71
|
+
- `black`: code formatter
|
|
72
|
+
- `isort`: import sorter
|
|
73
|
+
- `ruff`: code style checker
|
|
74
|
+
- `mypy`: type checker
|
|
75
|
+
- `bandit`: security analyzer
|
|
76
|
+
- `vulture`: unused code detector
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### 🧹 6. Clean build artifacts
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
make clean
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
### 💡 Notes
|
|
89
|
+
|
|
90
|
+
- Python 3.9+ is required.
|
|
91
|
+
- We use [Hatchling](https://hatch.pypa.io/latest/) as the build backend.
|
|
92
|
+
- Editable installs are handled with `hatch develop`.
|
|
93
|
+
- Development dependencies are managed via `[project.optional-dependencies].dev` in `pyproject.toml`.
|
|
94
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "excel-dbapi"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A DBAPI-like interface for Excel files with extensible engines"
|
|
9
|
+
authors = [{ name = "Yeongseon Choe", email = "yeongseon.choe@gmail.com" }]
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"openpyxl>=3.1.0",
|
|
13
|
+
"requests>=2.28.0"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=8.3.5,<9.0.0",
|
|
19
|
+
"black>=25.1.0,<26.0.0",
|
|
20
|
+
"isort>=6.0.1,<7.0.0",
|
|
21
|
+
"ruff>=0.11.2,<0.12.0",
|
|
22
|
+
"mypy>=1.15.0,<2.0.0",
|
|
23
|
+
"build",
|
|
24
|
+
"bandit>=1.7.0,<2.0.0",
|
|
25
|
+
"vulture>=2.0,<3.0",
|
|
26
|
+
"pre-commit",
|
|
27
|
+
"types-requests>=2.28.0,<3.0.0"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.mypy]
|
|
31
|
+
ignore_missing_imports = true
|
|
32
|
+
strict = false
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, Protocol, Union
|
|
4
|
+
|
|
5
|
+
from openpyxl.workbook.workbook import Workbook
|
|
6
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
7
|
+
|
|
8
|
+
from .engines.openpyxl_engine import OpenPyXLEngine
|
|
9
|
+
from .exceptions import OperationalError
|
|
10
|
+
from .remote import fetch_remote_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExcelEngine(Protocol):
|
|
14
|
+
"""Protocol for Excel processing engines."""
|
|
15
|
+
|
|
16
|
+
def load_workbook(self, file_path: Union[str, BytesIO]) -> "ExcelEngine": ...
|
|
17
|
+
def close(self) -> None: ...
|
|
18
|
+
def get_sheet(self, sheet_name: str) -> Worksheet: ...
|
|
19
|
+
@property
|
|
20
|
+
def workbook(self) -> Workbook: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExcelConnection:
|
|
24
|
+
"""DBAPI-compliant connection to an Excel file."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self, file_path: Union[str, Path], engine: Optional[ExcelEngine] = None
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Initialize with file path and optional engine."""
|
|
30
|
+
self.file_path = Path(file_path)
|
|
31
|
+
self.engine: ExcelEngine = engine if engine else OpenPyXLEngine()
|
|
32
|
+
self._connected = False
|
|
33
|
+
|
|
34
|
+
def connect(self) -> "ExcelConnection":
|
|
35
|
+
"""Establish connection to the Excel file."""
|
|
36
|
+
try:
|
|
37
|
+
if str(self.file_path).startswith(("http://", "https://")):
|
|
38
|
+
file_data = fetch_remote_file(self.file_path)
|
|
39
|
+
self.engine.load_workbook(file_data)
|
|
40
|
+
else:
|
|
41
|
+
self.engine.load_workbook(str(self.file_path))
|
|
42
|
+
self._connected = True
|
|
43
|
+
return self
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise OperationalError(f"Failed to connect to {self.file_path}: {e}")
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
"""Close the connection."""
|
|
49
|
+
if self._connected:
|
|
50
|
+
self.engine.close()
|
|
51
|
+
self._connected = False
|
|
52
|
+
|
|
53
|
+
def commit(self) -> None:
|
|
54
|
+
"""Commit changes (not applicable for read-only Excel, placeholder)."""
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
def rollback(self) -> None:
|
|
58
|
+
"""Rollback changes (not applicable, placeholder)."""
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
def cursor(self): # 타입 주석 제거 및 임포트 지연
|
|
62
|
+
"""Return a new cursor object."""
|
|
63
|
+
from .cursor import ExcelCursor # 메서드 안에서 임포트
|
|
64
|
+
|
|
65
|
+
if not self._connected:
|
|
66
|
+
raise OperationalError("Connection is not established.")
|
|
67
|
+
return ExcelCursor(self)
|
|
68
|
+
|
|
69
|
+
def __enter__(self) -> "ExcelConnection":
|
|
70
|
+
"""Context manager entry."""
|
|
71
|
+
return self.connect()
|
|
72
|
+
|
|
73
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
74
|
+
"""Context manager exit."""
|
|
75
|
+
self.close()
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from typing import Dict, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from .connection import ExcelConnection
|
|
4
|
+
from .exceptions import OperationalError, ProgrammingError
|
|
5
|
+
from .query import QueryEngine
|
|
6
|
+
from .table import ExcelTable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ExcelCursor:
|
|
10
|
+
"""DBAPI-compliant cursor for executing queries on Excel tables."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, connection: ExcelConnection) -> None:
|
|
13
|
+
"""Initialize with a connection."""
|
|
14
|
+
self.connection = connection
|
|
15
|
+
self.table: Optional[ExcelTable] = None
|
|
16
|
+
self._results: Optional[List[Dict[str, Union[str, int, float]]]] = None
|
|
17
|
+
self._rowcount = -1
|
|
18
|
+
|
|
19
|
+
def execute(self, query: str, params: Optional[tuple] = None) -> None:
|
|
20
|
+
"""Execute a SQL-like query."""
|
|
21
|
+
if not query.strip().upper().startswith("SELECT"):
|
|
22
|
+
raise ProgrammingError("Only SELECT queries are supported.")
|
|
23
|
+
|
|
24
|
+
if "FROM" not in query.upper():
|
|
25
|
+
raise ProgrammingError("Query must include FROM clause.")
|
|
26
|
+
|
|
27
|
+
table_name = query.split("FROM", 1)[1].strip().split()[0]
|
|
28
|
+
self.table = ExcelTable(self.connection, table_name)
|
|
29
|
+
|
|
30
|
+
with self.table:
|
|
31
|
+
qe = QueryEngine(self.table)
|
|
32
|
+
if "*" in query:
|
|
33
|
+
self._results = qe.select()
|
|
34
|
+
else:
|
|
35
|
+
columns = [
|
|
36
|
+
col.strip()
|
|
37
|
+
for col in query.split("SELECT")[1].split("FROM")[0].split(",")
|
|
38
|
+
]
|
|
39
|
+
self._results = qe.select(columns=columns)
|
|
40
|
+
self._rowcount = len(self._results or [])
|
|
41
|
+
|
|
42
|
+
def fetchone(self) -> Optional[Dict[str, Union[str, int, float]]]:
|
|
43
|
+
"""Fetch the next row of the query result."""
|
|
44
|
+
if self._results is None:
|
|
45
|
+
raise OperationalError("No query executed.")
|
|
46
|
+
if not self._results:
|
|
47
|
+
return None
|
|
48
|
+
return self._results.pop(0)
|
|
49
|
+
|
|
50
|
+
def fetchall(self) -> List[Dict[str, Union[str, int, float]]]:
|
|
51
|
+
"""Fetch all rows of the query result."""
|
|
52
|
+
if self._results is None:
|
|
53
|
+
raise OperationalError("No query executed.")
|
|
54
|
+
results = self._results or []
|
|
55
|
+
self._results = []
|
|
56
|
+
return results
|
|
57
|
+
|
|
58
|
+
def fetchmany(self, size: int = 1) -> List[Dict[str, Union[str, int, float]]]:
|
|
59
|
+
"""Fetch a specified number of rows."""
|
|
60
|
+
if self._results is None:
|
|
61
|
+
raise OperationalError("No query executed.")
|
|
62
|
+
results = self._results[:size]
|
|
63
|
+
self._results = self._results[size:]
|
|
64
|
+
return results
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def rowcount(self) -> int:
|
|
68
|
+
"""Return the number of rows affected."""
|
|
69
|
+
return self._rowcount
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
"""Close the cursor."""
|
|
73
|
+
self.table = None
|
|
74
|
+
self._results = None
|
|
75
|
+
self._rowcount = -1
|
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from typing import Optional, Union
|
|
3
|
+
|
|
4
|
+
import openpyxl
|
|
5
|
+
from openpyxl.workbook.workbook import Workbook
|
|
6
|
+
from openpyxl.worksheet.worksheet import Worksheet
|
|
7
|
+
|
|
8
|
+
from ..exceptions import OperationalError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenPyXLEngine:
|
|
12
|
+
"""Excel processing engine using openpyxl."""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
"""Initialize the engine."""
|
|
16
|
+
self._workbook: Optional[Workbook] = None
|
|
17
|
+
|
|
18
|
+
def load_workbook(self, file_path: Union[str, BytesIO]) -> "OpenPyXLEngine":
|
|
19
|
+
"""Load the workbook from a file path or BytesIO."""
|
|
20
|
+
try:
|
|
21
|
+
self._workbook = openpyxl.load_workbook(file_path)
|
|
22
|
+
return self
|
|
23
|
+
except Exception as e:
|
|
24
|
+
raise OperationalError(f"Failed to load workbook: {e}")
|
|
25
|
+
|
|
26
|
+
def close(self) -> None:
|
|
27
|
+
"""Close the workbook."""
|
|
28
|
+
if self._workbook:
|
|
29
|
+
self._workbook.close()
|
|
30
|
+
self._workbook = None
|
|
31
|
+
|
|
32
|
+
def get_sheet(self, sheet_name: str) -> Worksheet:
|
|
33
|
+
"""Get a specific sheet by name."""
|
|
34
|
+
if not self._workbook:
|
|
35
|
+
raise OperationalError("Workbook not loaded.")
|
|
36
|
+
try:
|
|
37
|
+
return self._workbook[sheet_name]
|
|
38
|
+
except KeyError:
|
|
39
|
+
raise OperationalError(f"Sheet '{sheet_name}' not found.")
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def workbook(self) -> Workbook:
|
|
43
|
+
"""Property to access the loaded workbook."""
|
|
44
|
+
if not self._workbook:
|
|
45
|
+
raise OperationalError("Workbook not loaded.")
|
|
46
|
+
return self._workbook
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class Error(Exception):
|
|
2
|
+
"""Base class for all DBAPI exceptions."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InterfaceError(Error):
|
|
8
|
+
"""Raised for errors related to the database interface."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DatabaseError(Error):
|
|
14
|
+
"""Raised for errors related to the database itself."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TableError(Exception):
|
|
20
|
+
"""Raised when a table-related error occurs."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OperationalError(DatabaseError):
|
|
26
|
+
"""Raised for operational errors (e.g., connection issues)."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProgrammingError(DatabaseError):
|
|
32
|
+
"""Raised for programming errors (e.g., invalid SQL)."""
|
|
33
|
+
|
|
34
|
+
pass
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from typing import Callable, Dict, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from .exceptions import OperationalError
|
|
4
|
+
from .table import ExcelTable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class QueryEngine:
|
|
8
|
+
"""Engine for executing SQL-like queries on Excel tables."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, table: ExcelTable) -> None:
|
|
11
|
+
"""Initialize with a table instance."""
|
|
12
|
+
self.table = table
|
|
13
|
+
|
|
14
|
+
def select(
|
|
15
|
+
self,
|
|
16
|
+
columns: Optional[List[str]] = None,
|
|
17
|
+
where: Optional[Callable[[List[Union[str, int, float]]], bool]] = None,
|
|
18
|
+
limit: Optional[int] = None,
|
|
19
|
+
) -> List[Dict[str, Union[str, int, float]]]:
|
|
20
|
+
"""Execute a SELECT query with optional columns, where clause, and limit."""
|
|
21
|
+
if not self.table.sheet:
|
|
22
|
+
raise OperationalError("Table must be opened before querying.")
|
|
23
|
+
|
|
24
|
+
headers = [cell.value for cell in self.table.sheet[1]]
|
|
25
|
+
if not headers or not all(isinstance(h, str) for h in headers):
|
|
26
|
+
raise OperationalError("First row must contain valid string headers.")
|
|
27
|
+
|
|
28
|
+
if columns:
|
|
29
|
+
col_indices = [headers.index(col) for col in columns if col in headers]
|
|
30
|
+
else:
|
|
31
|
+
col_indices = list(range(len(headers)))
|
|
32
|
+
|
|
33
|
+
rows = self.table.fetch_all()[1:]
|
|
34
|
+
result: List[Dict[str, Union[str, int, float]]] = []
|
|
35
|
+
|
|
36
|
+
for row in rows:
|
|
37
|
+
if where is None or where(row):
|
|
38
|
+
row_dict = {headers[i]: row[i] for i in col_indices}
|
|
39
|
+
result.append(row_dict)
|
|
40
|
+
if limit and len(result) >= limit:
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def query(
|
|
47
|
+
table: ExcelTable,
|
|
48
|
+
columns: Optional[List[str]] = None,
|
|
49
|
+
where: Optional[Callable[[List[Union[str, int, float]]], bool]] = None,
|
|
50
|
+
limit: Optional[int] = None,
|
|
51
|
+
) -> List[Dict[str, Union[str, int, float]]]:
|
|
52
|
+
"""Convenience function to execute a query."""
|
|
53
|
+
engine = QueryEngine(table)
|
|
54
|
+
return engine.select(columns=columns, where=where, limit=limit)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from io import BytesIO
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Union
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .exceptions import OperationalError # ConnectionError -> OperationalError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fetch_remote_file(url: Union[str, Path]) -> BytesIO:
|
|
11
|
+
"""Fetch a remote file and return it as BytesIO."""
|
|
12
|
+
try:
|
|
13
|
+
response = requests.get(str(url), timeout=10)
|
|
14
|
+
response.raise_for_status()
|
|
15
|
+
return BytesIO(response.content)
|
|
16
|
+
except requests.RequestException as e:
|
|
17
|
+
raise OperationalError(f"Failed to fetch remote file {url}: {e}")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
2
|
+
|
|
3
|
+
from .connection import ExcelConnection
|
|
4
|
+
from .exceptions import OperationalError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ExcelTable:
|
|
8
|
+
"""Class to treat an Excel sheet as a table."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, connection: ExcelConnection, sheet_name: str) -> None:
|
|
11
|
+
"""Initialize with a connection and sheet name."""
|
|
12
|
+
self.connection = connection
|
|
13
|
+
self.sheet_name = sheet_name
|
|
14
|
+
self.sheet: Optional[Any] = None
|
|
15
|
+
|
|
16
|
+
def open(self) -> "ExcelTable":
|
|
17
|
+
"""Open the sheet and prepare it as a table."""
|
|
18
|
+
try:
|
|
19
|
+
self.sheet = self.connection.engine.get_sheet(self.sheet_name)
|
|
20
|
+
return self
|
|
21
|
+
except Exception as e:
|
|
22
|
+
raise OperationalError(f"Failed to open sheet '{self.sheet_name}': {e}")
|
|
23
|
+
|
|
24
|
+
def fetch_all(self) -> List[List[Union[str, int, float]]]:
|
|
25
|
+
"""Fetch all data from the sheet."""
|
|
26
|
+
if not self.sheet:
|
|
27
|
+
raise OperationalError("Table is not opened.")
|
|
28
|
+
return [[cell.value for cell in row] for row in self.sheet.rows]
|
|
29
|
+
|
|
30
|
+
def fetch_row(self, row_num: int) -> List[Union[str, int, float]]:
|
|
31
|
+
"""Fetch a specific row by number (1-based index)."""
|
|
32
|
+
if not self.sheet:
|
|
33
|
+
raise OperationalError("Table is not opened.")
|
|
34
|
+
return [cell.value for cell in self.sheet[row_num]]
|
|
35
|
+
|
|
36
|
+
def query(
|
|
37
|
+
self,
|
|
38
|
+
columns: Optional[List[str]] = None,
|
|
39
|
+
where: Optional[Callable[[List[Union[str, int, float]]], bool]] = None,
|
|
40
|
+
limit: Optional[int] = None,
|
|
41
|
+
) -> List[Dict[str, Union[str, int, float]]]:
|
|
42
|
+
"""Execute a query on the table."""
|
|
43
|
+
from .query import query # lazy import to avoid circular dependency
|
|
44
|
+
|
|
45
|
+
return query(self, columns=columns, where=where, limit=limit)
|
|
46
|
+
|
|
47
|
+
def __enter__(self) -> "ExcelTable":
|
|
48
|
+
"""Context manager entry."""
|
|
49
|
+
return self.open()
|
|
50
|
+
|
|
51
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
52
|
+
"""Context manager exit."""
|
|
53
|
+
self.sheet = None
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from excel_dbapi.connection import ExcelConnection
|
|
4
|
+
from excel_dbapi.exceptions import OperationalError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_connection_local(tmp_path):
|
|
8
|
+
from openpyxl import Workbook
|
|
9
|
+
|
|
10
|
+
file_path = tmp_path / "test.xlsx"
|
|
11
|
+
wb = Workbook()
|
|
12
|
+
wb.save(file_path)
|
|
13
|
+
|
|
14
|
+
with ExcelConnection(file_path) as conn:
|
|
15
|
+
assert conn.engine.workbook is not None
|
|
16
|
+
|
|
17
|
+
with pytest.raises(OperationalError):
|
|
18
|
+
_ = conn.engine.workbook
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_connection_invalid_file():
|
|
22
|
+
with pytest.raises(OperationalError):
|
|
23
|
+
with ExcelConnection("nonexistent.xlsx"):
|
|
24
|
+
pass
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from excel_dbapi.connection import ExcelConnection
|
|
4
|
+
from excel_dbapi.exceptions import ProgrammingError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_cursor_execute_fetchall(tmp_path):
|
|
8
|
+
from openpyxl import Workbook
|
|
9
|
+
|
|
10
|
+
file_path = tmp_path / "test.xlsx"
|
|
11
|
+
wb = Workbook()
|
|
12
|
+
ws = wb.active
|
|
13
|
+
ws.append(["Name", "Age"])
|
|
14
|
+
ws.append(["Alice", 25])
|
|
15
|
+
ws.append(["Bob", 30])
|
|
16
|
+
wb.save(file_path)
|
|
17
|
+
|
|
18
|
+
with ExcelConnection(file_path) as conn:
|
|
19
|
+
cursor = conn.cursor()
|
|
20
|
+
cursor.execute("SELECT * FROM Sheet")
|
|
21
|
+
result = cursor.fetchall()
|
|
22
|
+
assert result == [{"Name": "Alice", "Age": 25}, {"Name": "Bob", "Age": 30}]
|
|
23
|
+
assert cursor.rowcount == 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_cursor_fetchone(tmp_path):
|
|
27
|
+
from openpyxl import Workbook
|
|
28
|
+
|
|
29
|
+
file_path = tmp_path / "test.xlsx"
|
|
30
|
+
wb = Workbook()
|
|
31
|
+
ws = wb.active
|
|
32
|
+
ws.append(["Name", "Age"])
|
|
33
|
+
ws.append(["Alice", 25])
|
|
34
|
+
wb.save(file_path)
|
|
35
|
+
|
|
36
|
+
with ExcelConnection(file_path) as conn:
|
|
37
|
+
cursor = conn.cursor()
|
|
38
|
+
cursor.execute("SELECT * FROM Sheet")
|
|
39
|
+
row = cursor.fetchone()
|
|
40
|
+
assert row == {"Name": "Alice", "Age": 25}
|
|
41
|
+
assert cursor.fetchone() is None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_cursor_invalid_query(tmp_path):
|
|
45
|
+
from openpyxl import Workbook
|
|
46
|
+
|
|
47
|
+
file_path = tmp_path / "test.xlsx"
|
|
48
|
+
wb = Workbook()
|
|
49
|
+
wb.save(file_path)
|
|
50
|
+
|
|
51
|
+
with ExcelConnection(file_path) as conn:
|
|
52
|
+
cursor = conn.cursor()
|
|
53
|
+
with pytest.raises(ProgrammingError):
|
|
54
|
+
cursor.execute("INSERT INTO Sheet")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from excel_dbapi.connection import ExcelConnection
|
|
2
|
+
from excel_dbapi.table import ExcelTable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_query_all(tmp_path):
|
|
6
|
+
from openpyxl import Workbook
|
|
7
|
+
|
|
8
|
+
file_path = tmp_path / "test.xlsx"
|
|
9
|
+
wb = Workbook()
|
|
10
|
+
ws = wb.active
|
|
11
|
+
ws.append(["Name", "Age", "City"])
|
|
12
|
+
ws.append(["Alice", 25, "Seoul"])
|
|
13
|
+
ws.append(["Bob", 30, "Tokyo"])
|
|
14
|
+
wb.save(file_path)
|
|
15
|
+
|
|
16
|
+
with ExcelConnection(file_path) as conn:
|
|
17
|
+
with ExcelTable(conn, "Sheet") as table:
|
|
18
|
+
result = table.query()
|
|
19
|
+
assert result == [
|
|
20
|
+
{"Name": "Alice", "Age": 25, "City": "Seoul"},
|
|
21
|
+
{"Name": "Bob", "Age": 30, "City": "Tokyo"},
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_query_columns(tmp_path):
|
|
26
|
+
from openpyxl import Workbook
|
|
27
|
+
|
|
28
|
+
file_path = tmp_path / "test.xlsx"
|
|
29
|
+
wb = Workbook()
|
|
30
|
+
ws = wb.active
|
|
31
|
+
ws.append(["Name", "Age", "City"])
|
|
32
|
+
ws.append(["Alice", 25, "Seoul"])
|
|
33
|
+
wb.save(file_path)
|
|
34
|
+
|
|
35
|
+
with ExcelConnection(file_path) as conn:
|
|
36
|
+
with ExcelTable(conn, "Sheet") as table:
|
|
37
|
+
result = table.query(columns=["Name"])
|
|
38
|
+
assert result == [{"Name": "Alice"}]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_query_where(tmp_path):
|
|
42
|
+
from openpyxl import Workbook
|
|
43
|
+
|
|
44
|
+
file_path = tmp_path / "test.xlsx"
|
|
45
|
+
wb = Workbook()
|
|
46
|
+
ws = wb.active
|
|
47
|
+
ws.append(["Name", "Age", "City"])
|
|
48
|
+
ws.append(["Alice", 25, "Seoul"])
|
|
49
|
+
ws.append(["Bob", 30, "Tokyo"])
|
|
50
|
+
wb.save(file_path)
|
|
51
|
+
|
|
52
|
+
with ExcelConnection(file_path) as conn:
|
|
53
|
+
with ExcelTable(conn, "Sheet") as table:
|
|
54
|
+
result = table.query(where=lambda row: row[1] > 25)
|
|
55
|
+
assert result == [{"Name": "Bob", "Age": 30, "City": "Tokyo"}]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_query_limit(tmp_path):
|
|
59
|
+
from openpyxl import Workbook
|
|
60
|
+
|
|
61
|
+
file_path = tmp_path / "test.xlsx"
|
|
62
|
+
wb = Workbook()
|
|
63
|
+
ws = wb.active
|
|
64
|
+
ws.append(["Name", "Age"])
|
|
65
|
+
ws.append(["Alice", 25])
|
|
66
|
+
ws.append(["Bob", 30])
|
|
67
|
+
wb.save(file_path)
|
|
68
|
+
|
|
69
|
+
with ExcelConnection(file_path) as conn:
|
|
70
|
+
with ExcelTable(conn, "Sheet") as table:
|
|
71
|
+
result = table.query(limit=1)
|
|
72
|
+
assert result == [{"Name": "Alice", "Age": 25}]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from excel_dbapi.connection import ExcelConnection
|
|
4
|
+
from excel_dbapi.exceptions import OperationalError
|
|
5
|
+
from excel_dbapi.table import ExcelTable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_table_fetch_all(tmp_path):
|
|
9
|
+
from openpyxl import Workbook
|
|
10
|
+
|
|
11
|
+
file_path = tmp_path / "test.xlsx"
|
|
12
|
+
wb = Workbook()
|
|
13
|
+
ws = wb.active
|
|
14
|
+
ws.append(["Name", "Age"])
|
|
15
|
+
ws.append(["Alice", 25])
|
|
16
|
+
ws.append(["Bob", 30])
|
|
17
|
+
wb.save(file_path)
|
|
18
|
+
|
|
19
|
+
with ExcelConnection(file_path) as conn:
|
|
20
|
+
with ExcelTable(conn, "Sheet") as table:
|
|
21
|
+
data = table.fetch_all()
|
|
22
|
+
assert data == [["Name", "Age"], ["Alice", 25], ["Bob", 30]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_table_fetch_row(tmp_path):
|
|
26
|
+
from openpyxl import Workbook
|
|
27
|
+
|
|
28
|
+
file_path = tmp_path / "test.xlsx"
|
|
29
|
+
wb = Workbook()
|
|
30
|
+
ws = wb.active
|
|
31
|
+
ws.append(["Name", "Age"])
|
|
32
|
+
ws.append(["Alice", 25])
|
|
33
|
+
wb.save(file_path)
|
|
34
|
+
|
|
35
|
+
with ExcelConnection(file_path) as conn:
|
|
36
|
+
with ExcelTable(conn, "Sheet") as table:
|
|
37
|
+
row = table.fetch_row(2)
|
|
38
|
+
assert row == ["Alice", 25]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_table_invalid_sheet(tmp_path):
|
|
42
|
+
from openpyxl import Workbook
|
|
43
|
+
|
|
44
|
+
file_path = tmp_path / "test.xlsx"
|
|
45
|
+
wb = Workbook()
|
|
46
|
+
wb.save(file_path)
|
|
47
|
+
|
|
48
|
+
with ExcelConnection(file_path) as conn:
|
|
49
|
+
with pytest.raises(OperationalError):
|
|
50
|
+
with ExcelTable(conn, "Nonexistent"):
|
|
51
|
+
pass
|