sapiopycommons 2024.8.27a310__py3-none-any.whl → 2024.8.27a312__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.

Potentially problematic release.


This version of sapiopycommons might be problematic. Click here for more details.

Files changed (33) hide show
  1. sapiopycommons/callbacks/callback_util.py +37 -133
  2. sapiopycommons/datatype/attachment_util.py +10 -11
  3. sapiopycommons/eln/experiment_handler.py +48 -209
  4. sapiopycommons/eln/experiment_report_util.py +129 -33
  5. sapiopycommons/files/complex_data_loader.py +4 -5
  6. sapiopycommons/files/file_bridge.py +14 -15
  7. sapiopycommons/files/file_bridge_handler.py +5 -27
  8. sapiopycommons/files/file_data_handler.py +5 -2
  9. sapiopycommons/files/file_util.py +5 -38
  10. sapiopycommons/files/file_validator.py +11 -26
  11. sapiopycommons/files/file_writer.py +15 -44
  12. sapiopycommons/general/aliases.py +3 -147
  13. sapiopycommons/general/custom_report_util.py +32 -34
  14. sapiopycommons/general/popup_util.py +0 -17
  15. sapiopycommons/general/time_util.py +0 -40
  16. sapiopycommons/multimodal/multimodal_data.py +1 -0
  17. sapiopycommons/processtracking/endpoints.py +22 -22
  18. sapiopycommons/recordmodel/record_handler.py +77 -228
  19. sapiopycommons/rules/eln_rule_handler.py +25 -34
  20. sapiopycommons/rules/on_save_rule_handler.py +31 -34
  21. sapiopycommons/webhook/webhook_handlers.py +26 -90
  22. {sapiopycommons-2024.8.27a310.dist-info → sapiopycommons-2024.8.27a312.dist-info}/METADATA +1 -1
  23. sapiopycommons-2024.8.27a312.dist-info/RECORD +43 -0
  24. sapiopycommons/customreport/__init__.py +0 -0
  25. sapiopycommons/customreport/column_builder.py +0 -60
  26. sapiopycommons/customreport/custom_report_builder.py +0 -125
  27. sapiopycommons/customreport/term_builder.py +0 -299
  28. sapiopycommons/general/audit_log.py +0 -196
  29. sapiopycommons/general/sapio_links.py +0 -50
  30. sapiopycommons/webhook/webservice_handlers.py +0 -67
  31. sapiopycommons-2024.8.27a310.dist-info/RECORD +0 -50
  32. {sapiopycommons-2024.8.27a310.dist-info → sapiopycommons-2024.8.27a312.dist-info}/WHEEL +0 -0
  33. {sapiopycommons-2024.8.27a310.dist-info → sapiopycommons-2024.8.27a312.dist-info}/licenses/LICENSE +0 -0
@@ -4,14 +4,13 @@ import urllib.parse
4
4
 
5
5
  from requests import Response
6
6
  from sapiopylib.rest.User import SapioUser
7
-
8
- from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
7
+ from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
9
8
 
10
9
 
11
10
  # FR-46064 - Initial port of PyWebhookUtils to sapiopycommons.
12
11
  class FileBridge:
13
12
  @staticmethod
14
- def read_file(context: UserIdentifier, bridge_name: str, file_path: str,
13
+ def read_file(context: SapioWebhookContext | SapioUser, bridge_name: str, file_path: str,
15
14
  base64_decode: bool = True) -> bytes:
16
15
  """
17
16
  Read a file from FileBridge.
@@ -28,7 +27,7 @@ class FileBridge:
28
27
  params = {
29
28
  'Filepath': f"bridge://{bridge_name}/{file_path}"
30
29
  }
31
- user: SapioUser = AliasUtil.to_sapio_user(context)
30
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
32
31
  response = user.get(sub_path, params)
33
32
  user.raise_for_status(response)
34
33
 
@@ -38,7 +37,7 @@ class FileBridge:
38
37
  return ret_val
39
38
 
40
39
  @staticmethod
41
- def write_file(context: UserIdentifier, bridge_name: str, file_path: str,
40
+ def write_file(context: SapioWebhookContext | SapioUser, bridge_name: str, file_path: str,
42
41
  file_data: bytes | str) -> None:
43
42
  """
44
43
  Write a file to FileBridge.
@@ -54,13 +53,13 @@ class FileBridge:
54
53
  params = {
55
54
  'Filepath': f"bridge://{bridge_name}/{file_path}"
56
55
  }
57
- user: SapioUser = AliasUtil.to_sapio_user(context)
58
- with io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data) as data_stream:
56
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
57
+ with io.StringIO(file_data) if isinstance(file_data, str) else io.BytesIO(file_data) as data_stream:
59
58
  response = user.post_data_stream(sub_path, params=params, data_stream=data_stream)
60
59
  user.raise_for_status(response)
61
60
 
62
61
  @staticmethod
63
- def list_directory(context: UserIdentifier, bridge_name: str,
62
+ def list_directory(context: SapioWebhookContext | SapioUser, bridge_name: str,
64
63
  file_path: str | None = "") -> list[str]:
65
64
  """
66
65
  List the contents of a FileBridge directory.
@@ -75,7 +74,7 @@ class FileBridge:
75
74
  params = {
76
75
  'Filepath': f"bridge://{bridge_name}/{file_path}"
77
76
  }
78
- user: SapioUser = AliasUtil.to_sapio_user(context)
77
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
79
78
  response: Response = user.get(sub_path, params=params)
80
79
  user.raise_for_status(response)
81
80
 
@@ -84,7 +83,7 @@ class FileBridge:
84
83
  return [urllib.parse.unquote(value)[path_length:] for value in response_body]
85
84
 
86
85
  @staticmethod
87
- def create_directory(context: UserIdentifier, bridge_name: str, file_path: str) -> None:
86
+ def create_directory(context: SapioWebhookContext | SapioUser, bridge_name: str, file_path: str) -> None:
88
87
  """
89
88
  Create a new directory in FileBridge.
90
89
 
@@ -98,12 +97,12 @@ class FileBridge:
98
97
  params = {
99
98
  'Filepath': f"bridge://{bridge_name}/{file_path}"
100
99
  }
101
- user: SapioUser = AliasUtil.to_sapio_user(context)
100
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
102
101
  response = user.post(sub_path, params=params)
103
102
  user.raise_for_status(response)
104
103
 
105
104
  @staticmethod
106
- def delete_file(context: UserIdentifier, bridge_name: str, file_path: str) -> None:
105
+ def delete_file(context: SapioWebhookContext | SapioUser, bridge_name: str, file_path: str) -> None:
107
106
  """
108
107
  Delete an existing file in FileBridge.
109
108
 
@@ -116,12 +115,12 @@ class FileBridge:
116
115
  params = {
117
116
  'Filepath': f"bridge://{bridge_name}/{file_path}"
118
117
  }
119
- user: SapioUser = AliasUtil.to_sapio_user(context)
118
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
120
119
  response = user.delete(sub_path, params=params)
121
120
  user.raise_for_status(response)
122
121
 
123
122
  @staticmethod
124
- def delete_directory(context: UserIdentifier, bridge_name: str, file_path: str) -> None:
123
+ def delete_directory(context: SapioWebhookContext | SapioUser, bridge_name: str, file_path: str) -> None:
125
124
  """
126
125
  Delete an existing directory in FileBridge.
127
126
 
@@ -134,6 +133,6 @@ class FileBridge:
134
133
  params = {
135
134
  'Filepath': f"bridge://{bridge_name}/{file_path}"
136
135
  }
137
- user: SapioUser = AliasUtil.to_sapio_user(context)
136
+ user: SapioUser = context if isinstance(context, SapioUser) else context.user
138
137
  response = user.delete(sub_path, params=params)
139
138
  user.raise_for_status(response)
@@ -1,12 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from abc import abstractmethod, ABC
4
- from weakref import WeakValueDictionary
5
-
6
- from sapiopylib.rest.User import SapioUser
7
4
 
8
5
  from sapiopycommons.files.file_bridge import FileBridge
9
- from sapiopycommons.general.aliases import AliasUtil, UserIdentifier
6
+ from sapiopylib.rest.User import SapioUser
7
+ from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
10
8
 
11
9
 
12
10
  class FileBridgeHandler:
@@ -25,33 +23,13 @@ class FileBridgeHandler:
25
23
  __directories: dict[str, Directory]
26
24
  """A cache of directory file paths to Directory objects."""
27
25
 
28
- __instances: WeakValueDictionary[str, FileBridgeHandler] = WeakValueDictionary()
29
- __initialized: bool
30
-
31
- def __new__(cls, context: UserIdentifier, bridge_name: str):
32
- """
33
- :param context: The current webhook context or a user object to send requests from.
34
- """
35
- user = AliasUtil.to_sapio_user(context)
36
- key = f"{user.__hash__()}:{bridge_name}"
37
- obj = cls.__instances.get(key)
38
- if not obj:
39
- obj = object.__new__(cls)
40
- obj.__initialized = False
41
- cls.__instances[key] = obj
42
- return obj
43
-
44
- def __init__(self, context: UserIdentifier, bridge_name: str):
26
+ def __init__(self, context: SapioWebhookContext | SapioUser, bridge_name: str):
45
27
  """
46
28
  :param context: The current webhook context or a user object to send requests from.
47
29
  :param bridge_name: The name of the bridge to communicate with. This is the "connection name" in the
48
30
  file bridge configurations.
49
31
  """
50
- if self.__initialized:
51
- return
52
- self.__initialized = True
53
-
54
- self.user = AliasUtil.to_sapio_user(context)
32
+ self.user = context if isinstance(context, SapioUser) else context.user
55
33
  self.__bridge = bridge_name
56
34
  self.__file_cache = {}
57
35
  self.__files = {}
@@ -328,7 +306,7 @@ class Directory(FileBridgeObject):
328
306
  return {x: y for x, y in self.contents.items() if not y.is_file()}
329
307
 
330
308
 
331
- def split_path(file_path: str) -> tuple[str, str]:
309
+ def split_path(file_path: str) -> (str, str):
332
310
  """
333
311
  :param file_path: A file path where directories are separated the "/" characters.
334
312
  :return: A tuple of two strings that splits the path on its last slash. The first string is the name of the
@@ -1,11 +1,14 @@
1
1
  import re
2
2
  from typing import Any, Callable, Iterable
3
3
 
4
- from sapiopycommons.general.aliases import SapioRecord
5
4
  from sapiopycommons.general.exceptions import SapioException
6
- from sapiopycommons.general.time_util import TimeUtil
5
+
7
6
  from sapiopycommons.recordmodel.record_handler import RecordHandler
8
7
 
8
+ from sapiopycommons.general.aliases import SapioRecord
9
+
10
+ from sapiopycommons.general.time_util import TimeUtil
11
+
9
12
  FilterList = Iterable[int] | range | Callable[[int, dict[str, Any]], bool] | None
10
13
  """A FilterList is an object used to determine if a row in the file data should be skipped over. This can take the
11
14
  form of am iterable (e.g. list, set) of its or a range where row indices in the list or range are skipped, or it can be
@@ -1,6 +1,4 @@
1
1
  import io
2
- import warnings
3
- import zipfile
4
2
 
5
3
  import pandas
6
4
  from numpy import dtype
@@ -23,8 +21,7 @@ class FileUtil:
23
21
  """
24
22
  @staticmethod
25
23
  def tokenize_csv(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
26
- seperator: str = ",", *, encoding: str | None = None, exception_on_empty: bool = True) \
27
- -> tuple[list[dict[str, str]], list[list[str]]]:
24
+ seperator: str = ",", *, encoding: str | None = None) -> tuple[list[dict[str, str]], list[list[str]]]:
28
25
  """
29
26
  Tokenize a CSV file. The provided file must be uniform. That is, if row 1 has 10 cells, all the rows in the file
30
27
  must have 10 cells. Otherwise, the Pandas parser throws a tokenizer exception.
@@ -40,8 +37,6 @@ class FileUtil:
40
37
  :param encoding: The encoding used to read the given file bytes. If not provided, uses utf-8. If your file
41
38
  contains a non-utf-8 character, then a UnicodeDecodeError will be thrown. If this happens, consider using
42
39
  ISO-8859-1 as the encoding.
43
- :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
44
- the first element of the returned tuple.
45
40
  :return: The CSV parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
46
41
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
47
42
  If the header row index is 0 or None, this list will be empty.
@@ -54,13 +49,11 @@ class FileUtil:
54
49
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
55
50
  # Parse the data from the file body into a list of dicts.
56
51
  rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
57
- if exception_on_empty and not rows:
58
- raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
59
52
  return rows, metadata
60
53
 
61
54
  @staticmethod
62
- def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
63
- *, exception_on_empty: bool = True) -> tuple[list[dict[str, str]], list[list[str]]]:
55
+ def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0) \
56
+ -> tuple[list[dict[str, str]], list[list[str]]]:
64
57
  """
65
58
  Tokenize an XLSX file row by row.
66
59
 
@@ -71,8 +64,6 @@ class FileUtil:
71
64
  row is returned in the metadata list. If input is None, then no row is considered to be the header row,
72
65
  meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
73
66
  is assumed to be the header row.
74
- :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
75
- the first element of the returned tuple.
76
67
  :return: The XLSX parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
77
68
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
78
69
  If the header row index is 0 or None, this list will be empty.
@@ -84,8 +75,6 @@ class FileUtil:
84
75
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
85
76
  # Parse the data from the file body into a list of dicts.
86
77
  rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
87
- if exception_on_empty and not rows:
88
- raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
89
78
  return rows, metadata
90
79
 
91
80
  @staticmethod
@@ -240,7 +229,7 @@ class FileUtil:
240
229
  :param file_data: The CSV file to be converted.
241
230
  :return: The bytes of the CSV file converted to an XLSX file.
242
231
  """
243
- with (io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)) as csv:
232
+ with (io.BytesIO(file_data) if isinstance(file_data, bytes) else io.StringIO(file_data)) as csv:
244
233
  # Setting header to false makes pandas read the CSV as-is.
245
234
  data_frame = pandas.read_csv(csv, sep=",", header=None)
246
235
 
@@ -284,20 +273,6 @@ class FileUtil:
284
273
  file_bytes: bytes = buffer.getvalue()
285
274
  return file_bytes
286
275
 
287
- @staticmethod
288
- def zip_files(files: dict[str, str | bytes]) -> bytes:
289
- """
290
- Create a zip file for a collection of files.
291
-
292
- :param files: A dictionary of file name to file data as a string or bytes.
293
- :return: The bytes for a zip file containing the input files.
294
- """
295
- zip_buffer: io.BytesIO = io.BytesIO()
296
- with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
297
- for file_name, file_data in files.items():
298
- zip_file.writestr(file_name, file_data)
299
- return zip_buffer.getvalue()
300
-
301
276
  # Deprecated functions:
302
277
 
303
278
  # FR-46097 - Add write file request shorthand functions to FileUtil.
@@ -315,8 +290,6 @@ class FileUtil:
315
290
  :param request_context: Context that will be returned to the webhook server in the client callback result.
316
291
  :return: A SapioWebhookResult with the write request as its client callback request.
317
292
  """
318
- warnings.warn("FileUtil.write_file is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
319
- DeprecationWarning)
320
293
  return SapioWebhookResult(True, client_callback_request=WriteFileRequest(file_bytes, file_name,
321
294
  request_context))
322
295
 
@@ -333,8 +306,6 @@ class FileUtil:
333
306
  :param request_context: Context that will be returned to the webhook server in the client callback result.
334
307
  :return: A SapioWebhookResult with the write request as its client callback request.
335
308
  """
336
- warnings.warn("FileUtil.write_files is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
337
- DeprecationWarning)
338
309
  return SapioWebhookResult(True, client_callback_request=MultiFileRequest(files, request_context))
339
310
 
340
311
  @staticmethod
@@ -362,8 +333,6 @@ class FileUtil:
362
333
  1 - The file name of the requested file if the user provided one.
363
334
  2 - The file bytes of the requested file if the user provided one.
364
335
  """
365
- warnings.warn("FileUtil.request_file is deprecated as of 24.5+. Use CallbackUtil.request_file instead.",
366
- DeprecationWarning)
367
336
  client_callback = context.client_callback_result
368
337
  result_context: str | None = client_callback.callback_context_data if client_callback else None
369
338
  # If the user cancels, terminate the interaction.
@@ -416,8 +385,6 @@ class FileUtil:
416
385
  May also contain a result that will terminate the client interaction if the user canceled the prompt.
417
386
  1 - A dictionary that maps the file names to the file bytes for each provided file.
418
387
  """
419
- warnings.warn("FileUtil.request_files is deprecated as of 24.5+. Use CallbackUtil.request_files instead.",
420
- DeprecationWarning)
421
388
  client_callback = context.client_callback_result
422
389
  result_context: str | None = client_callback.callback_context_data if client_callback else None
423
390
  # If the user cancels, terminate the interaction.
@@ -460,7 +427,7 @@ class FileUtil:
460
427
  if len(allowed_extensions) != 0:
461
428
  matches: bool = False
462
429
  for ext in allowed_extensions:
463
- if file_path.endswith("." + ext.lstrip(".")):
430
+ if file_path.endswith("." + ext):
464
431
  matches = True
465
432
  break
466
433
  if matches is False:
@@ -7,12 +7,12 @@ from sapiopylib.rest.User import SapioUser
7
7
  from sapiopylib.rest.pojo.CustomReport import RawReportTerm, RawTermOperation
8
8
  from sapiopylib.rest.pojo.datatype.FieldDefinition import VeloxIntegerFieldDefinition, VeloxStringFieldDefinition, \
9
9
  AbstractVeloxFieldDefinition
10
+ from sapiopylib.rest.pojo.webhook.WebhookContext import SapioWebhookContext
11
+ from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
10
12
 
11
13
  from sapiopycommons.callbacks.callback_util import CallbackUtil
12
14
  from sapiopycommons.files.file_data_handler import FileDataHandler, FilterList
13
- from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
14
15
  from sapiopycommons.general.custom_report_util import CustomReportUtil
15
- from sapiopycommons.general.exceptions import SapioUserCancelledException
16
16
  from sapiopycommons.general.time_util import TimeUtil
17
17
 
18
18
 
@@ -80,10 +80,10 @@ class FileValidator:
80
80
 
81
81
  return failed_rows
82
82
 
83
- def build_violation_report(self, context: UserIdentifier,
83
+ def build_violation_report(self, context: SapioWebhookResult | SapioUser,
84
84
  rule_violations: dict[int, list[ValidationRule]]) -> None:
85
85
  """
86
- Display a simple report of any rule violations in the file to the user as a table dialog.
86
+ Build a simple report of any rule violations in the file to display to the user as a table dialog.
87
87
 
88
88
  :param context: The current webhook context or a user object to send requests from.
89
89
  :param rule_violations: A dict of rule violations generated by a call to validate_file.
@@ -121,24 +121,9 @@ class FileValidator:
121
121
  "Reason": violation.reason[:2000]
122
122
  })
123
123
 
124
- callback = CallbackUtil(context)
125
- callback.table_dialog("Errors", "The following rule violations were encountered in the provided file.",
126
- columns, rows)
127
-
128
- def validate_and_report_errors(self, context: UserIdentifier) -> None:
129
- """
130
- Validate the file. If any rule violations are found, display a simple report of any rule violations in the file
131
- to the user as a table dialog and throw a SapioUserCancelled exception after the user acknowledges the dialog
132
- to end the webhook interaction.
133
-
134
- Shorthand for calling validate_file() and then build_violation_report() if there are any errors.
135
-
136
- :param context: The current webhook context or a user object to send requests from.
137
- """
138
- violations = self.validate_file()
139
- if violations:
140
- self.build_violation_report(context, violations)
141
- raise SapioUserCancelledException()
124
+ callback_util = CallbackUtil(context)
125
+ callback_util.table_dialog("Errors", "The following rule violations were encountered in the provided file.",
126
+ columns, rows)
142
127
 
143
128
 
144
129
  class ValidationRule:
@@ -509,7 +494,7 @@ class UniqueSystemValueRule(ColumnRule):
509
494
  data_type_name: str
510
495
  data_field_name: str
511
496
 
512
- def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
497
+ def __init__(self, context: SapioWebhookContext | SapioUser, header: str, data_type_name: str,
513
498
  data_field_name: str):
514
499
  """
515
500
  :param context: The current webhook context or a user object to send requests from.
@@ -517,7 +502,7 @@ class UniqueSystemValueRule(ColumnRule):
517
502
  :param data_type_name: The data type name to search on.
518
503
  :param data_field_name: The data field name to search on. This is expected to be a string field.
519
504
  """
520
- self.user = AliasUtil.to_sapio_user(context)
505
+ self.user = context.user if isinstance(context, SapioWebhookContext) else context
521
506
  self.data_type_name = data_type_name
522
507
  self.data_field_name = data_field_name
523
508
  super().__init__(header, f"This value already exists in the system.")
@@ -543,7 +528,7 @@ class ExistingSystemValueRule(ColumnRule):
543
528
  data_type_name: str
544
529
  data_field_name: str
545
530
 
546
- def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
531
+ def __init__(self, context: SapioWebhookContext | SapioUser, header: str, data_type_name: str,
547
532
  data_field_name: str):
548
533
  """
549
534
  :param context: The current webhook context or a user object to send requests from.
@@ -551,7 +536,7 @@ class ExistingSystemValueRule(ColumnRule):
551
536
  :param data_type_name: The data type name to search on.
552
537
  :param data_field_name: The data field name to search on. This is expected to be a string field.
553
538
  """
554
- self.user = AliasUtil.to_sapio_user(context)
539
+ self.user = context.user if isinstance(context, SapioWebhookContext) else context
555
540
  self.data_type_name = data_type_name
556
541
  self.data_field_name = data_field_name
557
542
  super().__init__(header, f"This value doesn't exist in the system.")
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import warnings
4
3
  from abc import abstractmethod
5
4
  from enum import Enum
6
5
  from typing import Any
@@ -19,7 +18,7 @@ class FileWriter:
19
18
  body: list[list[Any]]
20
19
  delimiter: str
21
20
  line_break: str
22
- column_definitions: dict[str, ColumnDef]
21
+ column_definitions: list[ColumnDef]
23
22
 
24
23
  def __init__(self, headers: list[str], delimiter: str = ",", line_break: str = "\r\n"):
25
24
  """
@@ -31,7 +30,7 @@ class FileWriter:
31
30
  self.delimiter = delimiter
32
31
  self.line_break = line_break
33
32
  self.body = []
34
- self.column_definitions = {}
33
+ self.column_definitions = []
35
34
 
36
35
  def add_row_list(self, row: list[Any]) -> None:
37
36
  """
@@ -66,49 +65,21 @@ class FileWriter:
66
65
  new_row.append(row.get(header, ""))
67
66
  self.body.append(new_row)
68
67
 
69
- def add_column_definition(self, header: str, column_def: ColumnDef) -> None:
68
+ def add_column_definitions(self, column_defs: list[ColumnDef]) -> None:
70
69
  """
71
- Add a new column definition to this FileWriter for a specific header.
70
+ Add new column definitions to this FileWriter. Column definitions are evaluated in the order they are added,
71
+ meaning that they map to the header with the equivalent index. Before the file is built, the number of column
72
+ definitions must equal the number of headers if any column definition is provided.
72
73
 
73
- ColumnDefs are only used if the build_file function is provided with a list of RowBundles. Every header must
74
- have a column definition if this is the case.
74
+ ColumnDefs are only used if the build_file function is provided with a list of RowBundles.
75
75
 
76
76
  Custom column definitions can be created by defining a class that extends ColumnDef and implements the print
77
77
  method.
78
78
 
79
- :param column_def: A column definitions to be used to construct the file when build_file is
79
+ :param column_defs: A list of column definitions to be used to construct the file when build_file is
80
80
  called.
81
- :param header: The header that this column definition is for. If a header is provided that isn't in the headers
82
- list, the header is appended to the end of the list.
83
81
  """
84
- if header not in self.headers:
85
- self.headers.append(header)
86
- self.column_definitions[header] = column_def
87
-
88
- def add_column_definitions(self, column_defs: dict[str, ColumnDef]) -> None:
89
- """
90
- Add new column definitions to this FileWriter.
91
-
92
- ColumnDefs are only used if the build_file function is provided with a list of RowBundles. Every header must
93
- have a column definition if this is the case.
94
-
95
- Custom column definitions can be created by defining a class that extends ColumnDef and implements the print
96
- method.
97
-
98
- :param column_defs: A dictionary of header names to column definitions to be used to construct the file when
99
- build_file is called.
100
- """
101
- # For backwards compatibility purposes, if column definitions are provided as a list,
102
- # add them in order of appearance of the headers. This will only work if the headers are defined first, though.
103
- if isinstance(column_defs, list):
104
- warnings.warn("Adding column definitions is no longer expected as a list. Continuing to provide a list to "
105
- "this function may result in undesirable behavior.", UserWarning)
106
- if not self.headers:
107
- raise SapioException("No headers provided to FileWriter before the column definitions were added.")
108
- for header, column_def in zip(self.headers, column_defs):
109
- self.column_definitions[header] = column_def
110
- for header, column_def in column_defs.items():
111
- self.add_column_definition(header, column_def)
82
+ self.column_definitions.extend(column_defs)
112
83
 
113
84
  def build_file(self, rows: list[RowBundle] | None = None, sorter=None, reverse: bool = False) -> str:
114
85
  """
@@ -129,10 +100,11 @@ class FileWriter:
129
100
  """
130
101
  # If any column definitions have been provided, the number of column definitions and headers must be equal.
131
102
  if self.column_definitions:
132
- for header in self.headers:
133
- if header not in self.column_definitions:
134
- raise SapioException(f"FileWriter has no column definition for the header {header}. If any column "
135
- f"definitions are provided, then all headers must have a column definition.")
103
+ def_count: int = len(self.column_definitions)
104
+ header_count: int = len(self.headers)
105
+ if def_count != header_count:
106
+ raise SapioException(f"FileWriter has {def_count} column definitions defined but {header_count} "
107
+ f"headers. The number of column definitions must equal the number of headers.")
136
108
  # If any RowBundles have been provided, there must be column definitions for mapping them to the file.
137
109
  elif rows:
138
110
  raise SapioException(f"FileWriter was given RowBundles but contains no column definitions for mapping "
@@ -158,8 +130,7 @@ class FileWriter:
158
130
  rows.sort(key=lambda x: x.index)
159
131
  for row in rows:
160
132
  new_row: list[Any] = []
161
- for header in self.headers:
162
- column = self.column_definitions[header]
133
+ for column in self.column_definitions:
163
134
  if column.may_skip and row.may_skip:
164
135
  new_row.append("")
165
136
  else: