excel-dbapi 0.1.0__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.
@@ -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()
excel_dbapi/cursor.py ADDED
@@ -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
excel_dbapi/query.py ADDED
@@ -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)
excel_dbapi/remote.py ADDED
@@ -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}")
excel_dbapi/table.py ADDED
@@ -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,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,13 @@
1
+ excel_dbapi/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ excel_dbapi/connection.py,sha256=0Fr463-qho3nlBt_w36qzzGj14pMVsLE6tCObOB4Jwo,2531
3
+ excel_dbapi/cursor.py,sha256=87jk2g8yC0iK80ZTyphGPOeHaWeYibagJCh1JaciC8M,2725
4
+ excel_dbapi/exceptions.py,sha256=V3wX_8SqFfnvMAAeQELEB8veWFszXZEieBxkJf5CEmo,608
5
+ excel_dbapi/query.py,sha256=UULdZqB1zbmlaXqR7pTRMNO_mnSb0T8_GpXOyItNBaE,1935
6
+ excel_dbapi/remote.py,sha256=1Q2w_1eT8dVioSJPsUrgg-I5bd6UcIohp7qUtThwHJ8,543
7
+ excel_dbapi/table.py,sha256=V1vBz66Mec5Na8-HBFGboyfN2gl3lvA3P8ew2LpKKJU,2000
8
+ excel_dbapi/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ excel_dbapi/engines/openpyxl_engine.py,sha256=PCvAwg_Ai51viMLTjFGZEqe7-KkS6GzRPjCP-OXCrU0,1483
10
+ excel_dbapi-0.1.0.dist-info/METADATA,sha256=CpPLVQ1meqfKj7wd0EZi9g8TcP2PJGHqSyhdubtotuE,811
11
+ excel_dbapi-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ excel_dbapi-0.1.0.dist-info/licenses/LICENSE,sha256=gBTzk1NFKuyZKqa5-8leVY816k7POZUMpw2_PKl2MsY,1071
13
+ excel_dbapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.