pyfastexcel 0.1.8__py3-none-win_amd64.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.
- pyfastexcel/__init__.py +21 -0
- pyfastexcel/_typing.py +31 -0
- pyfastexcel/chart.py +346 -0
- pyfastexcel/driver.py +290 -0
- pyfastexcel/enums.py +119 -0
- pyfastexcel/logformatter.py +38 -0
- pyfastexcel/pivot.py +124 -0
- pyfastexcel/pyfastexcel.dll +0 -0
- pyfastexcel/serializers.py +100 -0
- pyfastexcel/style.py +202 -0
- pyfastexcel/utils.py +184 -0
- pyfastexcel/validators.py +196 -0
- pyfastexcel/workbook.py +573 -0
- pyfastexcel/worksheet.py +1025 -0
- pyfastexcel/writer.py +148 -0
- pyfastexcel-0.1.8.dist-info/LICENSE +21 -0
- pyfastexcel-0.1.8.dist-info/METADATA +212 -0
- pyfastexcel-0.1.8.dist-info/RECORD +20 -0
- pyfastexcel-0.1.8.dist-info/WHEEL +5 -0
- pyfastexcel-0.1.8.dist-info/top_level.txt +1 -0
pyfastexcel/utils.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import re
|
|
5
|
+
import string
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
# from dataclasses import dataclass
|
|
9
|
+
from pydantic.dataclasses import dataclass
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from openpyxl_style_writer import CustomStyle
|
|
13
|
+
|
|
14
|
+
warnings.simplefilter('always', DeprecationWarning)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CommentText:
|
|
19
|
+
text: str
|
|
20
|
+
size: int | None = None
|
|
21
|
+
name: str | None = None
|
|
22
|
+
bold: bool | None = None
|
|
23
|
+
italic: bool | None = None
|
|
24
|
+
underline: Literal['single', 'double'] | None = None
|
|
25
|
+
strike: bool | None = None
|
|
26
|
+
vert_align: str | None = None
|
|
27
|
+
color: str | None = None
|
|
28
|
+
|
|
29
|
+
def to_dict(self):
|
|
30
|
+
result = {
|
|
31
|
+
k[0].upper() + k[1:]: v
|
|
32
|
+
for k, v in self.__dict__.items()
|
|
33
|
+
if v is not None and k != 'text'
|
|
34
|
+
}
|
|
35
|
+
if result.get('Vert_align') is not None:
|
|
36
|
+
result['VertAlign'] = result['Vert_align']
|
|
37
|
+
result['text'] = self.text
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Selection:
|
|
43
|
+
sq_ref: str
|
|
44
|
+
active_cell: str
|
|
45
|
+
pane: str
|
|
46
|
+
|
|
47
|
+
def to_dict(self):
|
|
48
|
+
return {
|
|
49
|
+
'sq_ref': self.sq_ref,
|
|
50
|
+
'active_cell': self.active_cell,
|
|
51
|
+
'pane': self.pane,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def set_debug_level(level: int):
|
|
56
|
+
logging.basicConfig(level=level)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def deprecated_warning(msg: str):
|
|
60
|
+
warnings.warn(
|
|
61
|
+
msg,
|
|
62
|
+
DeprecationWarning,
|
|
63
|
+
stacklevel=2,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def set_custom_style(style_name: str, style: CustomStyle) -> None:
|
|
68
|
+
from .style import StyleManager
|
|
69
|
+
|
|
70
|
+
StyleManager.set_custom_style(style_name, style)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_and_register_style(style: CustomStyle) -> None:
|
|
74
|
+
from .style import StyleManager
|
|
75
|
+
|
|
76
|
+
if not isinstance(style, CustomStyle):
|
|
77
|
+
raise TypeError(
|
|
78
|
+
f'Invalid type ({type(style)}). Style should be a CustomStyle object.',
|
|
79
|
+
)
|
|
80
|
+
set_custom_style(f'Custom Style {StyleManager._STYLE_ID}', style)
|
|
81
|
+
StyleManager._STYLE_ID += 1
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_and_format_value(
|
|
85
|
+
value: Any,
|
|
86
|
+
set_default_style: bool = True,
|
|
87
|
+
) -> tuple[Any, Literal['DEFAULT_STYLE']] | Any:
|
|
88
|
+
# Convert non-numeric value to string
|
|
89
|
+
value = f'{value}' if not isinstance(value, (int, float, str)) else value
|
|
90
|
+
# msgpec does not support np.float64, so we should convert
|
|
91
|
+
# it to python float.
|
|
92
|
+
value = float(value) if isinstance(value, float) else value
|
|
93
|
+
|
|
94
|
+
return (value, 'DEFAULT_STYLE') if set_default_style else value
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def transfer_string_slice_to_slice(string_slice: str) -> slice:
|
|
98
|
+
'''
|
|
99
|
+
Transfer a string slice to a slice object.
|
|
100
|
+
For example, 'A1:B2' will be transfered to slice(1, 2, 1).
|
|
101
|
+
'''
|
|
102
|
+
start, end = string_slice.split(':')
|
|
103
|
+
alpha_start, row_start = _separate_alpha_numeric(start)
|
|
104
|
+
alpha_end, row_end = _separate_alpha_numeric(end)
|
|
105
|
+
return slice(f'{alpha_start}{row_start}', f'{alpha_end}{row_end}')
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _separate_alpha_numeric(input_string: str) -> tuple[str, int]:
|
|
109
|
+
'''
|
|
110
|
+
Separate the alpha and numeric part of a string.
|
|
111
|
+
Return alpha_part at first index and num_part at second index.
|
|
112
|
+
'''
|
|
113
|
+
alpha_part = re.findall(r'[a-zA-Z]+', input_string)
|
|
114
|
+
num_part = re.findall(r'[0-9]+', input_string)
|
|
115
|
+
if len(alpha_part) == 0 or len(num_part) == 0:
|
|
116
|
+
raise ValueError(f'Invalid input string {input_string}.')
|
|
117
|
+
return alpha_part[0], int(num_part[0])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_valid_column(column: str) -> bool:
|
|
121
|
+
"""
|
|
122
|
+
Validate the alphabet part of the column.
|
|
123
|
+
"""
|
|
124
|
+
column = column.upper()
|
|
125
|
+
index = 0
|
|
126
|
+
for c in column:
|
|
127
|
+
index = index * 26 + (ord(c) - ord('A')) + 1
|
|
128
|
+
return 1 <= index <= 16384
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def column_to_index(column: str) -> int:
|
|
132
|
+
"""
|
|
133
|
+
Translate the column name to the column index.
|
|
134
|
+
"""
|
|
135
|
+
if not isinstance(column, str):
|
|
136
|
+
raise TypeError(f'Invalid type ({type(column)}). Column should be a string.')
|
|
137
|
+
if len(column) > 3:
|
|
138
|
+
raise ValueError(f"Invalid column ({column}). Maximum Column is 'XFD'.")
|
|
139
|
+
if not all(c in string.ascii_uppercase for c in column):
|
|
140
|
+
raise ValueError(f'Invalid column ({column}). Column should be in uppercase.')
|
|
141
|
+
if not _is_valid_column(column):
|
|
142
|
+
raise ValueError(f"Invalid column ({column}). Maximum Column is 'XFD'.")
|
|
143
|
+
column = column.upper()
|
|
144
|
+
index = 0
|
|
145
|
+
for c in column:
|
|
146
|
+
index = index * 26 + (ord(c) - ord('A')) + 1
|
|
147
|
+
return index
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def index_to_column(index: int) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Translate the index to the column name.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(index, int):
|
|
155
|
+
raise TypeError(f'Invalid type ({type(index)}). Index should be a string.')
|
|
156
|
+
if index < 1 or index > 16384:
|
|
157
|
+
raise ValueError(f'Invalid index ({index}). Index should less and equal to 16384.')
|
|
158
|
+
name = ''
|
|
159
|
+
while index > 0:
|
|
160
|
+
index, r = divmod(index - 1, 26)
|
|
161
|
+
name = chr(r + ord('A')) + name
|
|
162
|
+
return name
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cell_reference_to_index(index: str) -> tuple[int, int]:
|
|
166
|
+
"""
|
|
167
|
+
Return the row and column index of the given Excel cell reference.
|
|
168
|
+
"""
|
|
169
|
+
alpha, num = _separate_alpha_numeric(index)
|
|
170
|
+
column = column_to_index(alpha)
|
|
171
|
+
row = int(num)
|
|
172
|
+
return row - 1, column - 1
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _validate_cell_reference(index: str) -> bool:
|
|
176
|
+
alpha, num = _separate_alpha_numeric(index)
|
|
177
|
+
num = int(num)
|
|
178
|
+
if not all(c in string.ascii_uppercase for c in alpha):
|
|
179
|
+
raise ValueError(f'Invalid column ({alpha}). Column should be in uppercase.')
|
|
180
|
+
if not _is_valid_column(alpha):
|
|
181
|
+
raise ValueError(f"Invalid column ({alpha}). Maximum Column is 'XFD'.")
|
|
182
|
+
if num < 1 or num > 16384:
|
|
183
|
+
raise ValueError(f'Invalid index ({num}). Index should less and equal to 16384.')
|
|
184
|
+
return True
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
6
|
+
from typing import Any, Optional, Literal
|
|
7
|
+
|
|
8
|
+
from ._typing import CommentTextStructure, SetPanesSelection, Self
|
|
9
|
+
from .logformatter import formatter
|
|
10
|
+
from .utils import CommentText, Selection, cell_reference_to_index, _validate_cell_reference
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
style_formatter = logging.StreamHandler()
|
|
14
|
+
style_formatter.setFormatter(formatter)
|
|
15
|
+
|
|
16
|
+
logger.addHandler(style_formatter)
|
|
17
|
+
logger.propagate = False
|
|
18
|
+
|
|
19
|
+
_table_style_config: dict[str, int] = {
|
|
20
|
+
'TableStyleLight': 21,
|
|
21
|
+
'TableStyleMedium': 28,
|
|
22
|
+
'TableStyleDark': 11,
|
|
23
|
+
}
|
|
24
|
+
_table_style: set[str] = set()
|
|
25
|
+
_table_style.add('')
|
|
26
|
+
|
|
27
|
+
for s, num in _table_style_config.items():
|
|
28
|
+
for i in range(1, num + 1):
|
|
29
|
+
_table_style.add(f'{s}{i}')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TableValidator(BaseModel):
|
|
33
|
+
cell_range: str
|
|
34
|
+
name: str
|
|
35
|
+
style_name: str = Field(default='', strict=True)
|
|
36
|
+
show_first_column: bool = Field(default=True, strict=True)
|
|
37
|
+
show_last_column: bool = Field(default=True, strict=True)
|
|
38
|
+
show_row_stripes: bool = Field(default=False, strict=True)
|
|
39
|
+
show_column_stripes: bool = Field(default=True, strict=True)
|
|
40
|
+
validate_table: bool = Field(default=True, strict=True)
|
|
41
|
+
|
|
42
|
+
@field_validator('cell_range')
|
|
43
|
+
@classmethod
|
|
44
|
+
def validate_cell_range(cls, cell_range: str) -> str:
|
|
45
|
+
if ':' not in cell_range:
|
|
46
|
+
raise ValueError('Invalid cell range. Expected format: A1:B2')
|
|
47
|
+
cell_range_split = cell_range.split(':')
|
|
48
|
+
if len(cell_range_split) != 2:
|
|
49
|
+
raise ValueError('Invalid cell range. Expected format: A1:B2')
|
|
50
|
+
|
|
51
|
+
_validate_cell_reference(cell_range_split[0])
|
|
52
|
+
_validate_cell_reference(cell_range_split[1])
|
|
53
|
+
|
|
54
|
+
return cell_range
|
|
55
|
+
|
|
56
|
+
@field_validator('style_name')
|
|
57
|
+
@classmethod
|
|
58
|
+
def validate_style_name(cls, style_name: str) -> str:
|
|
59
|
+
if style_name not in _table_style:
|
|
60
|
+
raise ValueError(f'Invalid table style name. Expected one of {_table_style}')
|
|
61
|
+
return style_name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TableFinalValidation(BaseModel):
|
|
65
|
+
data: list
|
|
66
|
+
table_list: list[dict[str, Any]]
|
|
67
|
+
|
|
68
|
+
@model_validator(mode='after')
|
|
69
|
+
def validate_table_list(self) -> Self:
|
|
70
|
+
for t in self.table_list:
|
|
71
|
+
if t['validate_table'] is False:
|
|
72
|
+
continue
|
|
73
|
+
t_split = t['range'].split(':')
|
|
74
|
+
start_row, start_col = cell_reference_to_index(t_split[0])
|
|
75
|
+
end_row, end_col = cell_reference_to_index(t_split[1])
|
|
76
|
+
|
|
77
|
+
# Check if table range is valid, end_col should +1 because of length comparison
|
|
78
|
+
if end_col + 1 > len(self.data[start_row]):
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Invalid table range for {t['name']}. "
|
|
81
|
+
'Please write a row for table first row.'
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if len(set(self.data[start_row])) != len(self.data[start_row]):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
'Invalid table header. ' 'The first row contains duplicate values.'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
end_row = len(self.data) - 1 if end_row >= len(self.data) else end_row
|
|
90
|
+
for col in range(start_col, end_col + 1):
|
|
91
|
+
column_data = [
|
|
92
|
+
self.data[row][col] for row in range(start_row, end_row + 1) if self.data[row]
|
|
93
|
+
]
|
|
94
|
+
first_element = column_data[0]
|
|
95
|
+
seen = set(column_data[1:])
|
|
96
|
+
if first_element in seen:
|
|
97
|
+
raise ValueError('Invalid table data. Column contains duplicate values.')
|
|
98
|
+
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PanesValidator(BaseModel):
|
|
103
|
+
freeze: bool = Field(default=False, strict=True)
|
|
104
|
+
split: bool = Field(default=False, strict=True)
|
|
105
|
+
x_split: int = Field(default=0, strict=True)
|
|
106
|
+
y_split: int = Field(default=0, strict=True)
|
|
107
|
+
top_left_cell: str = Field(default='', strict=True)
|
|
108
|
+
active_pane: Literal['bottomLeft', 'bottomRight', 'topLeft', 'topRight', ''] = ''
|
|
109
|
+
selection: Optional[SetPanesSelection | list[Selection] | Selection] = None
|
|
110
|
+
|
|
111
|
+
@model_validator(mode='after')
|
|
112
|
+
def validate_panes(self) -> Self:
|
|
113
|
+
if self.x_split < 0 or self.y_split < 0:
|
|
114
|
+
raise ValueError('Split position should be positive.')
|
|
115
|
+
if self.top_left_cell != '':
|
|
116
|
+
_validate_cell_reference(self.top_left_cell)
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DataValidationValidator(BaseModel):
|
|
121
|
+
sq_ref: str = Field(default=False, strict=True)
|
|
122
|
+
set_range: Optional[list[int | float]] = None
|
|
123
|
+
input_msg: Optional[list[str]] = None
|
|
124
|
+
drop_list: Optional[list[str | int | float] | str] = None
|
|
125
|
+
error_msg: Optional[list[str]] = None
|
|
126
|
+
|
|
127
|
+
@field_validator('sq_ref')
|
|
128
|
+
@classmethod
|
|
129
|
+
def validate_style_name(cls, sq_ref: str) -> str:
|
|
130
|
+
if ':' in sq_ref:
|
|
131
|
+
sq_ref_list = sq_ref.split(':')
|
|
132
|
+
_validate_cell_reference(sq_ref_list[0])
|
|
133
|
+
_validate_cell_reference(sq_ref_list[1])
|
|
134
|
+
else:
|
|
135
|
+
_validate_cell_reference(sq_ref)
|
|
136
|
+
return sq_ref
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class AutoFilterValidator(BaseModel):
|
|
140
|
+
target_range: str
|
|
141
|
+
|
|
142
|
+
@field_validator('target_range')
|
|
143
|
+
@classmethod
|
|
144
|
+
def validate_target_range(cls, target_range: str) -> str:
|
|
145
|
+
if ':' not in target_range:
|
|
146
|
+
raise ValueError('Invalid target range. Target range should be in the format "A1:B2".')
|
|
147
|
+
target_list = target_range.split(':')
|
|
148
|
+
if len(target_list) != 2:
|
|
149
|
+
raise ValueError('Invalid target range. Target range should be in the format "A1:B2".')
|
|
150
|
+
_validate_cell_reference(target_list[0])
|
|
151
|
+
_validate_cell_reference(target_list[1])
|
|
152
|
+
|
|
153
|
+
return target_range
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CommentValidator(BaseModel):
|
|
157
|
+
cell: str
|
|
158
|
+
author: str
|
|
159
|
+
text: CommentTextStructure | CommentText | list[CommentText]
|
|
160
|
+
|
|
161
|
+
@field_validator('cell')
|
|
162
|
+
@classmethod
|
|
163
|
+
def validate_cell(cls, cell: str) -> str:
|
|
164
|
+
_validate_cell_reference(cell)
|
|
165
|
+
return cell
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Register validators and use them in the validate_call decorator
|
|
169
|
+
VALIDATORS = {
|
|
170
|
+
'create_table': TableValidator,
|
|
171
|
+
'set_panes': PanesValidator,
|
|
172
|
+
'set_data_validation': DataValidationValidator,
|
|
173
|
+
'auto_filter': AutoFilterValidator,
|
|
174
|
+
'add_comment': CommentValidator,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def validate_call(func):
|
|
179
|
+
def wrapper(*args, **kwargs):
|
|
180
|
+
func_name = func.__name__
|
|
181
|
+
if func_name not in VALIDATORS:
|
|
182
|
+
logger.warning(f'No validator found for function {func_name}. Skipping validation.')
|
|
183
|
+
return func(*args, **kwargs)
|
|
184
|
+
|
|
185
|
+
validator = VALIDATORS[func_name]
|
|
186
|
+
model_fields = validator.model_fields
|
|
187
|
+
has_self = 'self' in func.__code__.co_varnames
|
|
188
|
+
|
|
189
|
+
actual_args = args[1:] if has_self else args
|
|
190
|
+
|
|
191
|
+
_kwargs = dict(zip(model_fields, actual_args), **kwargs)
|
|
192
|
+
validator(**_kwargs)
|
|
193
|
+
|
|
194
|
+
return func(*args, **kwargs)
|
|
195
|
+
|
|
196
|
+
return wrapper
|