1
0

excel.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  1. # Copyright 2021-2024 Avaiga Private Limited
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
  4. # the License. You may obtain a copy of the License at
  5. #
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. #
  8. # Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
  9. # an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
  10. # specific language governing permissions and limitations under the License.
  11. import os
  12. from datetime import datetime, timedelta
  13. from os.path import isfile
  14. from typing import Any, Dict, List, Optional, Set, Tuple, Union
  15. import numpy as np
  16. import pandas as pd
  17. from openpyxl import load_workbook
  18. from taipy.config.common.scope import Scope
  19. from .._entity._reload import _self_reload
  20. from .._version._version_manager_factory import _VersionManagerFactory
  21. from ..exceptions.exceptions import ExposedTypeLengthMismatch, NonExistingExcelSheet, SheetNameLengthMismatch
  22. from ..job.job_id import JobId
  23. from ._abstract_file import _AbstractFileDataNodeMixin
  24. from ._abstract_tabular import _AbstractTabularDataNodeMixin
  25. from .data_node import DataNode
  26. from .data_node_id import DataNodeId, Edit
  27. class ExcelDataNode(DataNode, _AbstractFileDataNodeMixin, _AbstractTabularDataNodeMixin):
  28. """Data Node stored as an Excel file.
  29. The Excel file format is _xlsx_.
  30. Attributes:
  31. config_id (str): Identifier of this data node configuration. It must be a valid Python
  32. identifier.
  33. scope (Scope^): The scope of this data node.
  34. id (str): The unique identifier of this data node.
  35. owner_id (str): The identifier of the owner (sequence_id, scenario_id, cycle_id) or
  36. `None`.
  37. parent_ids (Optional[Set[str]]): The identifiers of the parent tasks or `None`.
  38. last_edit_date (datetime): The date and time of the last modification.
  39. edits (List[Edit^]): The ordered list of edits for that job.
  40. version (str): The string indicates the application version of the data node to instantiate. If not provided,
  41. the current version is used.
  42. validity_period (Optional[timedelta]): The duration implemented as a timedelta since the last edit date for
  43. which the data node can be considered up-to-date. Once the validity period has passed, the data node is
  44. considered stale and relevant tasks will run even if they are skippable (see the
  45. [Task management page](../core/entities/task-mgt.md) for more details).
  46. If _validity_period_ is set to `None`, the data node is always up-to-date.
  47. edit_in_progress (bool): True if a task computing the data node has been submitted
  48. and not completed yet. False otherwise.
  49. editor_id (Optional[str]): The identifier of the user who is currently editing the data node.
  50. editor_expiration_date (Optional[datetime]): The expiration date of the editor lock.
  51. path (str): The path to the Excel file.
  52. properties (dict[str, Any]): A dictionary of additional properties. The _properties_
  53. must have a _"default_path"_ or _"path"_ entry with the path of the Excel file:
  54. - _"default_path"_ `(str)`: The path of the Excel file.\n
  55. - _"has_header"_ `(bool)`: If True, indicates that the Excel file has a header.\n
  56. - _"sheet_name"_ `(Union[List[str], str])`: The list of sheet names to be used. This
  57. can be a unique name.\n
  58. - _"exposed_type"_: The exposed type of the data read from Excel file. The default value is `pandas`.\n
  59. """
  60. __STORAGE_TYPE = "excel"
  61. __PATH_KEY = "path"
  62. __DEFAULT_DATA_KEY = "default_data"
  63. __DEFAULT_PATH_KEY = "default_path"
  64. __SHEET_NAME_PROPERTY = "sheet_name"
  65. _REQUIRED_PROPERTIES: List[str] = []
  66. def __init__(
  67. self,
  68. config_id: str,
  69. scope: Scope,
  70. id: Optional[DataNodeId] = None,
  71. owner_id: Optional[str] = None,
  72. parent_ids: Optional[Set[str]] = None,
  73. last_edit_date: Optional[datetime] = None,
  74. edits: List[Edit] = None,
  75. version: str = None,
  76. validity_period: Optional[timedelta] = None,
  77. edit_in_progress: bool = False,
  78. editor_id: Optional[str] = None,
  79. editor_expiration_date: Optional[datetime] = None,
  80. properties: Dict = None,
  81. ):
  82. if properties is None:
  83. properties = {}
  84. default_value = properties.pop(self.__DEFAULT_DATA_KEY, None)
  85. self._path = properties.get(self.__PATH_KEY, properties.get(self.__DEFAULT_PATH_KEY))
  86. properties[self.__PATH_KEY] = self._path
  87. if self.__SHEET_NAME_PROPERTY not in properties.keys():
  88. properties[self.__SHEET_NAME_PROPERTY] = None
  89. if self._HAS_HEADER_PROPERTY not in properties.keys():
  90. properties[self._HAS_HEADER_PROPERTY] = True
  91. properties[self._EXPOSED_TYPE_PROPERTY] = _AbstractTabularDataNodeMixin._get_valid_exposed_type(properties)
  92. self._check_exposed_type(properties[self._EXPOSED_TYPE_PROPERTY])
  93. DataNode.__init__(
  94. self,
  95. config_id,
  96. scope,
  97. id,
  98. owner_id,
  99. parent_ids,
  100. last_edit_date,
  101. edits,
  102. version or _VersionManagerFactory._build_manager()._get_latest_version(),
  103. validity_period,
  104. edit_in_progress,
  105. editor_id,
  106. editor_expiration_date,
  107. **properties,
  108. )
  109. _AbstractTabularDataNodeMixin.__init__(self, **properties)
  110. if self._path and ".data" in self._path:
  111. self._path = self._migrate_path(self.storage_type(), self._path)
  112. if not self._path:
  113. self._path = self._build_path(self.storage_type())
  114. properties[self.__PATH_KEY] = self._path
  115. if default_value is not None and not os.path.exists(self._path):
  116. self._write(default_value)
  117. self._last_edit_date = datetime.now()
  118. self._edits.append(
  119. Edit(
  120. {
  121. "timestamp": self._last_edit_date,
  122. "writer_identifier": "TAIPY",
  123. "comments": "Default data written.",
  124. }
  125. )
  126. )
  127. if not self._last_edit_date and isfile(self._path):
  128. self._last_edit_date = datetime.now()
  129. self._TAIPY_PROPERTIES.update(
  130. {
  131. self._EXPOSED_TYPE_PROPERTY,
  132. self.__PATH_KEY,
  133. self.__DEFAULT_PATH_KEY,
  134. self.__DEFAULT_DATA_KEY,
  135. self._HAS_HEADER_PROPERTY,
  136. self.__SHEET_NAME_PROPERTY,
  137. }
  138. )
  139. @property # type: ignore
  140. @_self_reload(DataNode._MANAGER_NAME)
  141. def path(self):
  142. return self._path
  143. @path.setter
  144. def path(self, value):
  145. self._path = value
  146. self.properties[self.__PATH_KEY] = value
  147. @classmethod
  148. def storage_type(cls) -> str:
  149. return cls.__STORAGE_TYPE
  150. @staticmethod
  151. def _check_exposed_type(exposed_type):
  152. if isinstance(exposed_type, str):
  153. _AbstractTabularDataNodeMixin._check_exposed_type(exposed_type)
  154. elif isinstance(exposed_type, list):
  155. for t in exposed_type:
  156. _AbstractTabularDataNodeMixin._check_exposed_type(t)
  157. elif isinstance(exposed_type, dict):
  158. for t in exposed_type.values():
  159. _AbstractTabularDataNodeMixin._check_exposed_type(t)
  160. def _read(self):
  161. if self.properties[self._EXPOSED_TYPE_PROPERTY] == self._EXPOSED_TYPE_PANDAS:
  162. return self._read_as_pandas_dataframe()
  163. if self.properties[self._EXPOSED_TYPE_PROPERTY] == self._EXPOSED_TYPE_NUMPY:
  164. return self._read_as_numpy()
  165. return self._read_as()
  166. def _read_as(self):
  167. try:
  168. excel_file = load_workbook(self._path)
  169. exposed_type = self.properties[self._EXPOSED_TYPE_PROPERTY]
  170. work_books = {}
  171. sheet_names = excel_file.sheetnames
  172. user_provided_sheet_names = self.properties.get(self.__SHEET_NAME_PROPERTY) or []
  173. if not isinstance(user_provided_sheet_names, (List, Set, Tuple)):
  174. user_provided_sheet_names = [user_provided_sheet_names]
  175. provided_sheet_names = user_provided_sheet_names or sheet_names
  176. for sheet_name in provided_sheet_names:
  177. if sheet_name not in sheet_names:
  178. raise NonExistingExcelSheet(sheet_name, self._path)
  179. if isinstance(exposed_type, List):
  180. if len(provided_sheet_names) != len(self.properties[self._EXPOSED_TYPE_PROPERTY]):
  181. raise ExposedTypeLengthMismatch(
  182. f"Expected {len(provided_sheet_names)} exposed types, got "
  183. f"{len(self.properties[self._EXPOSED_TYPE_PROPERTY])}"
  184. )
  185. for i, sheet_name in enumerate(provided_sheet_names):
  186. work_sheet = excel_file[sheet_name]
  187. sheet_exposed_type = exposed_type
  188. if not isinstance(sheet_exposed_type, str):
  189. if isinstance(exposed_type, dict):
  190. sheet_exposed_type = exposed_type.get(sheet_name, self._EXPOSED_TYPE_PANDAS)
  191. elif isinstance(exposed_type, List):
  192. sheet_exposed_type = exposed_type[i]
  193. if isinstance(sheet_exposed_type, str):
  194. if sheet_exposed_type == self._EXPOSED_TYPE_NUMPY:
  195. work_books[sheet_name] = self._read_as_pandas_dataframe(sheet_name).to_numpy()
  196. elif sheet_exposed_type == self._EXPOSED_TYPE_PANDAS:
  197. work_books[sheet_name] = self._read_as_pandas_dataframe(sheet_name)
  198. continue
  199. res = [[col.value for col in row] for row in work_sheet.rows]
  200. if self.properties[self._HAS_HEADER_PROPERTY] and res:
  201. header = res.pop(0)
  202. for i, row in enumerate(res):
  203. res[i] = sheet_exposed_type(**dict([[h, r] for h, r in zip(header, row)]))
  204. else:
  205. for i, row in enumerate(res):
  206. res[i] = sheet_exposed_type(*row)
  207. work_books[sheet_name] = res
  208. finally:
  209. excel_file.close()
  210. if len(provided_sheet_names) == 1:
  211. return work_books[provided_sheet_names[0]]
  212. return work_books
  213. def _read_as_numpy(self):
  214. sheets = self._read_as_pandas_dataframe()
  215. if isinstance(sheets, dict):
  216. return {sheet_name: df.to_numpy() for sheet_name, df in sheets.items()}
  217. return sheets.to_numpy()
  218. def _do_read_excel(self, sheet_names, kwargs) -> Union[Dict[Union[int, str], pd.DataFrame], pd.DataFrame]:
  219. return pd.read_excel(self._path, sheet_name=sheet_names, **kwargs)
  220. def __get_sheet_names_and_header(self, sheet_names):
  221. kwargs = {}
  222. if sheet_names is None:
  223. sheet_names = self.properties[self.__SHEET_NAME_PROPERTY]
  224. if not self.properties[self._HAS_HEADER_PROPERTY]:
  225. kwargs["header"] = None
  226. return sheet_names, kwargs
  227. def _read_as_pandas_dataframe(self, sheet_names=None) -> Union[Dict[Union[int, str], pd.DataFrame], pd.DataFrame]:
  228. sheet_names, kwargs = self.__get_sheet_names_and_header(sheet_names)
  229. try:
  230. return self._do_read_excel(sheet_names, kwargs)
  231. except pd.errors.EmptyDataError:
  232. return pd.DataFrame()
  233. def __append_excel_with_single_sheet(self, append_excel_fct, *args, **kwargs):
  234. sheet_name = self.properties.get(self.__SHEET_NAME_PROPERTY)
  235. with pd.ExcelWriter(self._path, mode="a", engine="openpyxl", if_sheet_exists="overlay") as writer:
  236. if sheet_name:
  237. if not isinstance(sheet_name, str):
  238. sheet_name = sheet_name[0]
  239. append_excel_fct(
  240. writer, *args, **kwargs, sheet_name=sheet_name, startrow=writer.sheets[sheet_name].max_row
  241. )
  242. else:
  243. sheet_name = list(writer.sheets.keys())[0]
  244. append_excel_fct(writer, *args, **kwargs, startrow=writer.sheets[sheet_name].max_row)
  245. def __append_excel_with_multiple_sheets(self, data: Any, columns: List[str] = None):
  246. with pd.ExcelWriter(self._path, mode="a", engine="openpyxl", if_sheet_exists="overlay") as writer:
  247. # Each key stands for a sheet name
  248. for sheet_name in data.keys():
  249. if isinstance(data[sheet_name], np.ndarray):
  250. df = pd.DataFrame(data[sheet_name])
  251. else:
  252. df = data[sheet_name]
  253. if columns:
  254. data[sheet_name].columns = columns
  255. df.to_excel(
  256. writer, sheet_name=sheet_name, index=False, header=False, startrow=writer.sheets[sheet_name].max_row
  257. )
  258. def _append(self, data: Any):
  259. from importlib.metadata import version
  260. if version("pandas") < "1.4":
  261. raise ImportError("The append method is only available for pandas version 1.4 or higher.")
  262. if isinstance(data, Dict) and all(isinstance(x, (pd.DataFrame, np.ndarray)) for x in data.values()):
  263. self.__append_excel_with_multiple_sheets(data)
  264. elif isinstance(data, pd.DataFrame):
  265. self.__append_excel_with_single_sheet(data.to_excel, index=False, header=False)
  266. else:
  267. self.__append_excel_with_single_sheet(pd.DataFrame(data).to_excel, index=False, header=False)
  268. def __write_excel_with_single_sheet(self, write_excel_fct, *args, **kwargs):
  269. if sheet_name := self.properties.get(self.__SHEET_NAME_PROPERTY):
  270. if not isinstance(sheet_name, str):
  271. if len(sheet_name) > 1:
  272. raise SheetNameLengthMismatch
  273. else:
  274. sheet_name = sheet_name[0]
  275. write_excel_fct(*args, **kwargs, sheet_name=sheet_name)
  276. else:
  277. write_excel_fct(*args, **kwargs)
  278. def __write_excel_with_multiple_sheets(self, data: Any, columns: List[str] = None):
  279. with pd.ExcelWriter(self._path) as writer:
  280. # Each key stands for a sheet name
  281. for key in data.keys():
  282. df = self._convert_data_to_dataframe(self.properties[self._EXPOSED_TYPE_PROPERTY], data[key])
  283. if columns:
  284. data[key].columns = columns
  285. df.to_excel(writer, key, index=False, header=self.properties[self._HAS_HEADER_PROPERTY] or None)
  286. def _write(self, data: Any):
  287. if isinstance(data, Dict):
  288. return self.__write_excel_with_multiple_sheets(data)
  289. else:
  290. data = self._convert_data_to_dataframe(self.properties[self._EXPOSED_TYPE_PROPERTY], data)
  291. self.__write_excel_with_single_sheet(
  292. data.to_excel, self._path, index=False, header=self.properties[self._HAS_HEADER_PROPERTY] or None
  293. )
  294. def write_with_column_names(self, data: Any, columns: List[str] = None, job_id: Optional[JobId] = None):
  295. """Write a set of columns.
  296. Parameters:
  297. data (Any): The data to write.
  298. columns (List[str]): The list of column names to write.
  299. job_id (JobId^): An optional identifier of the writer.
  300. """
  301. if isinstance(data, Dict) and all(isinstance(x, (pd.DataFrame, np.ndarray)) for x in data.values()):
  302. self.__write_excel_with_multiple_sheets(data, columns=columns)
  303. else:
  304. df = pd.DataFrame(data)
  305. if columns:
  306. df.columns = pd.Index(columns, dtype="object")
  307. self.__write_excel_with_single_sheet(df.to_excel, self.path, index=False)
  308. self.track_edit(timestamp=datetime.now(), job_id=job_id)