sapiopycommons 2024.11.11a364__py3-none-any.whl → 2024.11.18a366__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 (47) hide show
  1. sapiopycommons/callbacks/callback_util.py +532 -83
  2. sapiopycommons/callbacks/field_builder.py +537 -0
  3. sapiopycommons/chem/IndigoMolecules.py +2 -0
  4. sapiopycommons/chem/Molecules.py +77 -18
  5. sapiopycommons/customreport/__init__.py +0 -0
  6. sapiopycommons/customreport/column_builder.py +60 -0
  7. sapiopycommons/customreport/custom_report_builder.py +130 -0
  8. sapiopycommons/customreport/term_builder.py +299 -0
  9. sapiopycommons/datatype/attachment_util.py +11 -10
  10. sapiopycommons/datatype/data_fields.py +61 -0
  11. sapiopycommons/datatype/pseudo_data_types.py +440 -0
  12. sapiopycommons/eln/experiment_handler.py +272 -70
  13. sapiopycommons/eln/experiment_report_util.py +653 -0
  14. sapiopycommons/files/complex_data_loader.py +5 -4
  15. sapiopycommons/files/file_bridge.py +31 -24
  16. sapiopycommons/files/file_bridge_handler.py +340 -0
  17. sapiopycommons/files/file_data_handler.py +2 -5
  18. sapiopycommons/files/file_util.py +59 -9
  19. sapiopycommons/files/file_validator.py +92 -6
  20. sapiopycommons/files/file_writer.py +44 -15
  21. sapiopycommons/flowcyto/flow_cyto.py +77 -0
  22. sapiopycommons/flowcyto/flowcyto_data.py +75 -0
  23. sapiopycommons/general/accession_service.py +375 -0
  24. sapiopycommons/general/aliases.py +207 -6
  25. sapiopycommons/general/audit_log.py +189 -0
  26. sapiopycommons/general/custom_report_util.py +212 -37
  27. sapiopycommons/general/exceptions.py +21 -8
  28. sapiopycommons/general/popup_util.py +21 -0
  29. sapiopycommons/general/sapio_links.py +50 -0
  30. sapiopycommons/general/time_util.py +8 -2
  31. sapiopycommons/multimodal/multimodal.py +146 -0
  32. sapiopycommons/multimodal/multimodal_data.py +490 -0
  33. sapiopycommons/processtracking/custom_workflow_handler.py +406 -0
  34. sapiopycommons/processtracking/endpoints.py +22 -22
  35. sapiopycommons/recordmodel/record_handler.py +481 -97
  36. sapiopycommons/rules/eln_rule_handler.py +34 -25
  37. sapiopycommons/rules/on_save_rule_handler.py +34 -31
  38. sapiopycommons/sftpconnect/__init__.py +0 -0
  39. sapiopycommons/sftpconnect/sftp_builder.py +69 -0
  40. sapiopycommons/webhook/webhook_context.py +39 -0
  41. sapiopycommons/webhook/webhook_handlers.py +201 -42
  42. sapiopycommons/webhook/webservice_handlers.py +67 -0
  43. {sapiopycommons-2024.11.11a364.dist-info → sapiopycommons-2024.11.18a366.dist-info}/METADATA +5 -2
  44. sapiopycommons-2024.11.18a366.dist-info/RECORD +59 -0
  45. {sapiopycommons-2024.11.11a364.dist-info → sapiopycommons-2024.11.18a366.dist-info}/WHEEL +1 -1
  46. sapiopycommons-2024.11.11a364.dist-info/RECORD +0 -38
  47. {sapiopycommons-2024.11.11a364.dist-info → sapiopycommons-2024.11.18a366.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,6 @@
1
1
  import io
2
+ import warnings
3
+ import zipfile
2
4
 
3
5
  import pandas
4
6
  from numpy import dtype
@@ -21,7 +23,8 @@ class FileUtil:
21
23
  """
22
24
  @staticmethod
23
25
  def tokenize_csv(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
24
- seperator: str = ",") -> tuple[list[dict[str, str]], list[list[str]]]:
26
+ seperator: str = ",", *, encoding: str | None = None, encoding_error: str | None = "strict",
27
+ exception_on_empty: bool = True) -> tuple[list[dict[str, str]], list[list[str]]]:
25
28
  """
26
29
  Tokenize a CSV file. The provided file must be uniform. That is, if row 1 has 10 cells, all the rows in the file
27
30
  must have 10 cells. Otherwise, the Pandas parser throws a tokenizer exception.
@@ -34,22 +37,34 @@ class FileUtil:
34
37
  meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
35
38
  is assumed to be the header row.
36
39
  :param seperator: The character that separates cells in the table.
40
+ :param encoding: The encoding used to read the given file bytes. If not provided, uses utf-8. If your file
41
+ contains a non-utf-8 character, then a UnicodeDecodeError will be thrown. If this happens, consider using
42
+ ISO-8859-1 as the encoding, or investigate what encoding would handle the characters in your file.
43
+ :param encoding_error: The error handling behavior if an encoding error is encountered. By default, the behavior
44
+ is "strict", meaning that encoding errors raise an exception. Change this to "ignore" to skip over invalid
45
+ characters or "replace" to replace invalid characters with a ? character. For a full list of options, see
46
+ https://docs.python.org/3/library/codecs.html#error-handlers
47
+ :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
48
+ the first element of the returned tuple.
37
49
  :return: The CSV parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
38
50
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
39
51
  If the header row index is 0 or None, this list will be empty.
40
52
  """
41
53
  # Parse the file bytes into two DataFrames. The first is metadata of the file located above the header row,
42
54
  # while the second is the body of the file below the header row.
43
- file_body, file_metadata = FileUtil.csv_to_data_frames(file_bytes, header_row_index, seperator)
55
+ file_body, file_metadata = FileUtil.csv_to_data_frames(file_bytes, header_row_index, seperator,
56
+ encoding=encoding, encoding_error=encoding_error)
44
57
  # Parse the metadata from above the header row index into a list of lists.
45
58
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
46
59
  # Parse the data from the file body into a list of dicts.
47
60
  rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
61
+ if exception_on_empty and not rows:
62
+ raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
48
63
  return rows, metadata
49
64
 
50
65
  @staticmethod
51
- def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0) \
52
- -> tuple[list[dict[str, str]], list[list[str]]]:
66
+ def tokenize_xlsx(file_bytes: bytes, required_headers: list[str] | None = None, header_row_index: int | None = 0,
67
+ *, exception_on_empty: bool = True) -> tuple[list[dict[str, str]], list[list[str]]]:
53
68
  """
54
69
  Tokenize an XLSX file row by row.
55
70
 
@@ -60,6 +75,8 @@ class FileUtil:
60
75
  row is returned in the metadata list. If input is None, then no row is considered to be the header row,
61
76
  meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
62
77
  is assumed to be the header row.
78
+ :param exception_on_empty: Throw a user error exception if the provided file bytes result in an empty list in
79
+ the first element of the returned tuple.
63
80
  :return: The XLSX parsed into a list of dicts where each dict is a row, mapping the headers to the cells for
64
81
  that row. Also returns a list of each row above the headers (the metadata), parsed into a list of each cell.
65
82
  If the header row index is 0 or None, this list will be empty.
@@ -71,10 +88,13 @@ class FileUtil:
71
88
  metadata: list[list[str]] = FileUtil.data_frame_to_lists(file_metadata)
72
89
  # Parse the data from the file body into a list of dicts.
73
90
  rows: list[dict[str, str]] = FileUtil.data_frame_to_dicts(file_body, required_headers, header_row_index)
91
+ if exception_on_empty and not rows:
92
+ raise SapioUserErrorException("The provided file contains no rows of information below the headers.")
74
93
  return rows, metadata
75
94
 
76
95
  @staticmethod
77
- def csv_to_data_frames(file_bytes: bytes, header_row_index: int | None = 0, seperator: str = ",") \
96
+ def csv_to_data_frames(file_bytes: bytes, header_row_index: int | None = 0, seperator: str = ",",
97
+ *, encoding: str | None = None, encoding_error: str | None = "strict") \
78
98
  -> tuple[DataFrame, DataFrame | None]:
79
99
  """
80
100
  Parse the file bytes for a CSV into DataFrames. The provided file must be uniform. That is, if row 1 has 10
@@ -86,6 +106,13 @@ class FileUtil:
86
106
  meaning that required headers are also ignored if any are provided. By default, the first row (0th index)
87
107
  is assumed to be the header row.
88
108
  :param seperator: The character that separates cells in the table.
109
+ :param encoding: The encoding used to read the given file bytes. If not provided, uses utf-8. If your file
110
+ contains a non-utf-8 character, then a UnicodeDecodeError will be thrown. If this happens, consider using
111
+ ISO-8859-1 as the encoding, or investigate what encoding would handle the characters in your file.
112
+ :param encoding_error: The error handling behavior if an encoding error is encountered. By default, the behavior
113
+ is "strict", meaning that encoding errors raise an exception. Change this to "ignore" to skip over invalid
114
+ characters or "replace" to replace invalid characters with a ? character. For a full list of options, see
115
+ https://docs.python.org/3/library/codecs.html#error-handlers
89
116
  :return: A tuple of two DataFrames. The first is the frame for the CSV table body, while the second is for the
90
117
  metadata from above the header row, or None if there is no metadata.
91
118
  """
@@ -97,13 +124,14 @@ class FileUtil:
97
124
  # can throw off the header row index.
98
125
  file_metadata = pandas.read_csv(file_io, header=None, dtype=dtype(str),
99
126
  skiprows=lambda x: x >= header_row_index,
100
- skip_blank_lines=False, sep=seperator)
127
+ skip_blank_lines=False, sep=seperator, encoding=encoding,
128
+ encoding_errors=encoding_error)
101
129
  with io.BytesIO(file_bytes) as file_io:
102
130
  # The use of the dtype argument is to ensure that everything from the file gets read as a string. Added
103
131
  # because some numerical values would get ".0" appended to them, even when casting the DataFrame cell to a
104
132
  # string.
105
133
  file_body: DataFrame = pandas.read_csv(file_io, header=header_row_index, dtype=dtype(str),
106
- skip_blank_lines=False, sep=seperator)
134
+ skip_blank_lines=False, sep=seperator, encoding=encoding)
107
135
 
108
136
  return file_body, file_metadata
109
137
 
@@ -222,7 +250,7 @@ class FileUtil:
222
250
  :param file_data: The CSV file to be converted.
223
251
  :return: The bytes of the CSV file converted to an XLSX file.
224
252
  """
225
- with (io.BytesIO(file_data) if isinstance(file_data, bytes) else io.StringIO(file_data)) as csv:
253
+ with (io.BytesIO(file_data.encode() if isinstance(file_data, str) else file_data)) as csv:
226
254
  # Setting header to false makes pandas read the CSV as-is.
227
255
  data_frame = pandas.read_csv(csv, sep=",", header=None)
228
256
 
@@ -266,6 +294,20 @@ class FileUtil:
266
294
  file_bytes: bytes = buffer.getvalue()
267
295
  return file_bytes
268
296
 
297
+ @staticmethod
298
+ def zip_files(files: dict[str, str | bytes]) -> bytes:
299
+ """
300
+ Create a zip file for a collection of files.
301
+
302
+ :param files: A dictionary of file name to file data as a string or bytes.
303
+ :return: The bytes for a zip file containing the input files.
304
+ """
305
+ zip_buffer: io.BytesIO = io.BytesIO()
306
+ with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
307
+ for file_name, file_data in files.items():
308
+ zip_file.writestr(file_name, file_data)
309
+ return zip_buffer.getvalue()
310
+
269
311
  # Deprecated functions:
270
312
 
271
313
  # FR-46097 - Add write file request shorthand functions to FileUtil.
@@ -283,6 +325,8 @@ class FileUtil:
283
325
  :param request_context: Context that will be returned to the webhook server in the client callback result.
284
326
  :return: A SapioWebhookResult with the write request as its client callback request.
285
327
  """
328
+ warnings.warn("FileUtil.write_file is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
329
+ DeprecationWarning)
286
330
  return SapioWebhookResult(True, client_callback_request=WriteFileRequest(file_bytes, file_name,
287
331
  request_context))
288
332
 
@@ -299,6 +343,8 @@ class FileUtil:
299
343
  :param request_context: Context that will be returned to the webhook server in the client callback result.
300
344
  :return: A SapioWebhookResult with the write request as its client callback request.
301
345
  """
346
+ warnings.warn("FileUtil.write_files is deprecated as of 24.5+. Use CallbackUtil.write_file instead.",
347
+ DeprecationWarning)
302
348
  return SapioWebhookResult(True, client_callback_request=MultiFileRequest(files, request_context))
303
349
 
304
350
  @staticmethod
@@ -326,6 +372,8 @@ class FileUtil:
326
372
  1 - The file name of the requested file if the user provided one.
327
373
  2 - The file bytes of the requested file if the user provided one.
328
374
  """
375
+ warnings.warn("FileUtil.request_file is deprecated as of 24.5+. Use CallbackUtil.request_file instead.",
376
+ DeprecationWarning)
329
377
  client_callback = context.client_callback_result
330
378
  result_context: str | None = client_callback.callback_context_data if client_callback else None
331
379
  # If the user cancels, terminate the interaction.
@@ -378,6 +426,8 @@ class FileUtil:
378
426
  May also contain a result that will terminate the client interaction if the user canceled the prompt.
379
427
  1 - A dictionary that maps the file names to the file bytes for each provided file.
380
428
  """
429
+ warnings.warn("FileUtil.request_files is deprecated as of 24.5+. Use CallbackUtil.request_files instead.",
430
+ DeprecationWarning)
381
431
  client_callback = context.client_callback_result
382
432
  result_context: str | None = client_callback.callback_context_data if client_callback else None
383
433
  # If the user cancels, terminate the interaction.
@@ -420,7 +470,7 @@ class FileUtil:
420
470
  if len(allowed_extensions) != 0:
421
471
  matches: bool = False
422
472
  for ext in allowed_extensions:
423
- if file_path.endswith("." + ext):
473
+ if file_path.endswith("." + ext.lstrip(".")):
424
474
  matches = True
425
475
  break
426
476
  if matches is False:
@@ -4,12 +4,15 @@ from abc import abstractmethod
4
4
  from typing import Any
5
5
 
6
6
  from sapiopylib.rest.User import SapioUser
7
+ from sapiopylib.rest.pojo.CustomReport import RawReportTerm, RawTermOperation
7
8
  from sapiopylib.rest.pojo.datatype.FieldDefinition import VeloxIntegerFieldDefinition, VeloxStringFieldDefinition, \
8
9
  AbstractVeloxFieldDefinition
9
- from sapiopylib.rest.pojo.webhook.WebhookResult import SapioWebhookResult
10
10
 
11
11
  from sapiopycommons.callbacks.callback_util import CallbackUtil
12
12
  from sapiopycommons.files.file_data_handler import FileDataHandler, FilterList
13
+ from sapiopycommons.general.aliases import UserIdentifier, AliasUtil
14
+ from sapiopycommons.general.custom_report_util import CustomReportUtil
15
+ from sapiopycommons.general.exceptions import SapioUserCancelledException
13
16
  from sapiopycommons.general.time_util import TimeUtil
14
17
 
15
18
 
@@ -77,10 +80,10 @@ class FileValidator:
77
80
 
78
81
  return failed_rows
79
82
 
80
- def build_violation_report(self, context: SapioWebhookResult | SapioUser,
83
+ def build_violation_report(self, context: UserIdentifier,
81
84
  rule_violations: dict[int, list[ValidationRule]]) -> None:
82
85
  """
83
- Build a simple report of any rule violations in the file to display to the user as a table dialog.
86
+ Display a simple report of any rule violations in the file to the user as a table dialog.
84
87
 
85
88
  :param context: The current webhook context or a user object to send requests from.
86
89
  :param rule_violations: A dict of rule violations generated by a call to validate_file.
@@ -118,9 +121,24 @@ class FileValidator:
118
121
  "Reason": violation.reason[:2000]
119
122
  })
120
123
 
121
- callback_util = CallbackUtil(context)
122
- callback_util.table_dialog("Errors", "The following rule violations were encountered in the provided file.",
123
- columns, rows)
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
142
 
125
143
 
126
144
  class ValidationRule:
@@ -480,3 +498,71 @@ class ContainsSubstringFromCellRule(RowRule):
480
498
 
481
499
  def validate(self, row: dict[str, Any]) -> bool:
482
500
  return row.get(self.second) in row.get(self.first)
501
+
502
+
503
+ class UniqueSystemValueRule(ColumnRule):
504
+ """
505
+ Requires that every cell in the column has a value that is not already in use in the system for a given data type
506
+ and field name.
507
+ """
508
+ user: SapioUser
509
+ data_type_name: str
510
+ data_field_name: str
511
+
512
+ def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
513
+ data_field_name: str):
514
+ """
515
+ :param context: The current webhook context or a user object to send requests from.
516
+ :param header: The header that this rule acts upon.
517
+ :param data_type_name: The data type name to search on.
518
+ :param data_field_name: The data field name to search on. This is expected to be a string field.
519
+ """
520
+ self.user = AliasUtil.to_sapio_user(context)
521
+ self.data_type_name = data_type_name
522
+ self.data_field_name = data_field_name
523
+ super().__init__(header, f"This value already exists in the system.")
524
+
525
+ def validate(self, rows: list[dict[str, Any]]) -> list[int]:
526
+ file_handler = FileDataHandler(rows)
527
+ values: list[str] = file_handler.get_values_list(self.header)
528
+
529
+ # Run a quick report for all records of this type that match these field values.
530
+ term = RawReportTerm(self.data_type_name, self.data_field_name, RawTermOperation.EQUAL_TO_OPERATOR,
531
+ "{" + ",".join(values) + "}")
532
+ results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, term)
533
+ existing_values: list[Any] = [x.get(self.data_field_name) for x in results]
534
+ return file_handler.get_in_list(self.header, existing_values)
535
+
536
+
537
+ class ExistingSystemValueRule(ColumnRule):
538
+ """
539
+ Requires that every cell in the column has a value that is already in use in the system for a given data type
540
+ and field name.
541
+ """
542
+ user: SapioUser
543
+ data_type_name: str
544
+ data_field_name: str
545
+
546
+ def __init__(self, context: UserIdentifier, header: str, data_type_name: str,
547
+ data_field_name: str):
548
+ """
549
+ :param context: The current webhook context or a user object to send requests from.
550
+ :param header: The header that this rule acts upon.
551
+ :param data_type_name: The data type name to search on.
552
+ :param data_field_name: The data field name to search on. This is expected to be a string field.
553
+ """
554
+ self.user = AliasUtil.to_sapio_user(context)
555
+ self.data_type_name = data_type_name
556
+ self.data_field_name = data_field_name
557
+ super().__init__(header, f"This value doesn't exist in the system.")
558
+
559
+ def validate(self, rows: list[dict[str, Any]]) -> list[int]:
560
+ file_handler = FileDataHandler(rows)
561
+ values: list[str] = file_handler.get_values_list(self.header)
562
+
563
+ # Run a quick report for all records of this type that match these field values.
564
+ term = RawReportTerm(self.data_type_name, self.data_field_name, RawTermOperation.EQUAL_TO_OPERATOR,
565
+ "{" + ",".join(values) + "}")
566
+ results: list[dict[str, Any]] = CustomReportUtil.run_quick_report(self.user, term)
567
+ existing_values: list[Any] = [x.get(self.data_field_name) for x in results]
568
+ return file_handler.get_not_in_list(self.header, existing_values)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import warnings
3
4
  from abc import abstractmethod
4
5
  from enum import Enum
5
6
  from typing import Any
@@ -18,7 +19,7 @@ class FileWriter:
18
19
  body: list[list[Any]]
19
20
  delimiter: str
20
21
  line_break: str
21
- column_definitions: list[ColumnDef]
22
+ column_definitions: dict[str, ColumnDef]
22
23
 
23
24
  def __init__(self, headers: list[str], delimiter: str = ",", line_break: str = "\r\n"):
24
25
  """
@@ -30,7 +31,7 @@ class FileWriter:
30
31
  self.delimiter = delimiter
31
32
  self.line_break = line_break
32
33
  self.body = []
33
- self.column_definitions = []
34
+ self.column_definitions = {}
34
35
 
35
36
  def add_row_list(self, row: list[Any]) -> None:
36
37
  """
@@ -65,21 +66,49 @@ class FileWriter:
65
66
  new_row.append(row.get(header, ""))
66
67
  self.body.append(new_row)
67
68
 
68
- def add_column_definitions(self, column_defs: list[ColumnDef]) -> None:
69
+ def add_column_definition(self, header: str, column_def: ColumnDef) -> None:
69
70
  """
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.
71
+ Add a new column definition to this FileWriter for a specific header.
73
72
 
74
- ColumnDefs are only used if the build_file function is provided with a list of RowBundles.
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.
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_defs: A list of column definitions to be used to construct the file when build_file is
79
+ :param column_def: A 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.
81
83
  """
82
- self.column_definitions.extend(column_defs)
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)
83
112
 
84
113
  def build_file(self, rows: list[RowBundle] | None = None, sorter=None, reverse: bool = False) -> str:
85
114
  """
@@ -100,11 +129,10 @@ class FileWriter:
100
129
  """
101
130
  # If any column definitions have been provided, the number of column definitions and headers must be equal.
102
131
  if self.column_definitions:
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.")
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.")
108
136
  # If any RowBundles have been provided, there must be column definitions for mapping them to the file.
109
137
  elif rows:
110
138
  raise SapioException(f"FileWriter was given RowBundles but contains no column definitions for mapping "
@@ -130,7 +158,8 @@ class FileWriter:
130
158
  rows.sort(key=lambda x: x.index)
131
159
  for row in rows:
132
160
  new_row: list[Any] = []
133
- for column in self.column_definitions:
161
+ for header in self.headers:
162
+ column = self.column_definitions[header]
134
163
  if column.may_skip and row.may_skip:
135
164
  new_row.append("")
136
165
  else:
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from weakref import WeakValueDictionary
4
+
5
+ from sapiopylib.rest.User import SapioUser
6
+ from databind.json import dumps
7
+
8
+ from sapiopycommons.flowcyto.flowcyto_data import FlowJoWorkspaceInputJson, UploadFCSInputJson, \
9
+ ComputeFlowStatisticsInputJson
10
+
11
+
12
+ class FlowCytoManager:
13
+ """
14
+ This manager includes flow cytometry analysis tools that would require FlowCyto license to use.
15
+ """
16
+ _user: SapioUser
17
+
18
+ __instances: WeakValueDictionary[SapioUser, FlowCytoManager] = WeakValueDictionary()
19
+ __initialized: bool
20
+
21
+ def __new__(cls, user: SapioUser):
22
+ """
23
+ Observes singleton pattern per record model manager object.
24
+
25
+ :param user: The user that will make the webservice request to the application.
26
+ """
27
+ obj = cls.__instances.get(user)
28
+ if not obj:
29
+ obj = object.__new__(cls)
30
+ obj.__initialized = False
31
+ cls.__instances[user] = obj
32
+ return obj
33
+
34
+ def __init__(self, user: SapioUser):
35
+ if self.__initialized:
36
+ return
37
+ self._user = user
38
+ self.__initialized = True
39
+
40
+ def create_flowjo_workspace(self, workspace_input: FlowJoWorkspaceInputJson) -> int:
41
+ """
42
+ Create FlowJo Workspace and return the workspace record ID of workspace root record,
43
+ after successful creation.
44
+ :param workspace_input: the request data payload.
45
+ :return: The new workspace record ID.
46
+ """
47
+ payload = dumps(workspace_input, FlowJoWorkspaceInputJson)
48
+ response = self._user.plugin_post("flowcyto/workspace", payload=payload, is_payload_plain_text=True)
49
+ self._user.raise_for_status(response)
50
+ return int(response.json())
51
+
52
+ def upload_fcs_for_sample(self, upload_input: UploadFCSInputJson) -> int:
53
+ """
54
+ Upload FCS file as root of the sample FCS.
55
+ :param upload_input: The request data payload
56
+ :return: The root FCS file uploaded under sample.
57
+ """
58
+ payload = dumps(upload_input, UploadFCSInputJson)
59
+ response = self._user.plugin_post("flowcyto/fcs", payload=payload, is_payload_plain_text=True)
60
+ self._user.raise_for_status(response)
61
+ return int(response.json())
62
+
63
+ def compute_statistics(self, stat_compute_input: ComputeFlowStatisticsInputJson) -> list[int]:
64
+ """
65
+ Requests to compute flow cytometry statistics.
66
+ The children are of type FCSStatistic.
67
+ If the FCS files have not been evaluated yet,
68
+ then the lazy evaluation will be performed immediately prior to computing statistics, which can take longer.
69
+ If any new statistics are computed as children of FCS, they will be returned in the result record id list.
70
+ Note: if input has multiple FCS files, the client should try to get parent FCS file from each record to figure out which one is for which FCS.
71
+ :param stat_compute_input:
72
+ :return:
73
+ """
74
+ payload = dumps(stat_compute_input, ComputeFlowStatisticsInputJson)
75
+ response = self._user.plugin_post("flowcyto/statistics", payload=payload, is_payload_plain_text=True)
76
+ self._user.raise_for_status(response)
77
+ return list(response.json())
@@ -0,0 +1,75 @@
1
+ import base64
2
+ from enum import Enum
3
+
4
+ from databind.core.dataclasses import dataclass
5
+
6
+
7
+ class ChannelStatisticType(Enum):
8
+ """
9
+ All supported channel statistics type.
10
+ """
11
+ MEAN = "(Mean) MFI"
12
+ MEDIAN = "(Median) MFI"
13
+ STD_EV = "Std. Dev."
14
+ COEFFICIENT_OF_VARIATION = "CV"
15
+
16
+ display_name: str
17
+
18
+ def __init__(self, display_name: str):
19
+ self.display_name = display_name
20
+
21
+
22
+ @dataclass
23
+ class ChannelStatisticsParameterJSON:
24
+ channelNameList: list[str]
25
+ statisticsType: ChannelStatisticType
26
+
27
+ def __init__(self, channel_name_list: list[str], stat_type: ChannelStatisticType):
28
+ self.channelNameList = channel_name_list
29
+ self.statisticsType = stat_type
30
+
31
+
32
+ @dataclass
33
+ class ComputeFlowStatisticsInputJson:
34
+ fcsFileRecordIdList: list[int]
35
+ statisticsParameterList: list[ChannelStatisticsParameterJSON]
36
+
37
+ def __init__(self, fcs_file_record_id_list: list[int], statistics_parameter_list: list[ChannelStatisticsParameterJSON]):
38
+ self.fcsFileRecordIdList = fcs_file_record_id_list
39
+ self.statisticsParameterList = statistics_parameter_list
40
+
41
+
42
+ @dataclass
43
+ class FlowJoWorkspaceInputJson:
44
+ filePath: str
45
+ base64Data: str
46
+
47
+ def __init__(self, filePath: str, file_data: bytes):
48
+ self.filePath = filePath
49
+ self.base64Data = base64.b64encode(file_data).decode('utf-8')
50
+
51
+
52
+ @dataclass
53
+ class UploadFCSInputJson:
54
+ """
55
+ Request to upload new FCS file
56
+ Attributes:
57
+ filePath: The file name of the FCS file to be uploaded. For FlowJo workspace, this is important to match the file in group (via file names).
58
+ attachmentDataType: the attachment data type that contains already-uploaded FCS data.
59
+ attachmentRecordId: the attachment record ID that contains already-uploaded FCS data.
60
+ associatedRecordDataType: the "parent" association for the FCS. Can either be a workspace or a sample record.
61
+ associatedRecordId: the "parent" association for the FCS. Can either be a workspace or a sample record.
62
+ """
63
+ filePath: str
64
+ attachmentDataType: str
65
+ attachmentRecordId: int
66
+ associatedRecordDataType: str
67
+ associatedRecordId: int
68
+
69
+ def __init__(self, associated_record_data_type: str, associated_record_id: int,
70
+ file_path: str, attachment_data_type: str, attachment_record_id: int):
71
+ self.filePath = file_path
72
+ self.attachmentDataType = attachment_data_type
73
+ self.attachmentRecordId = attachment_record_id
74
+ self.associatedRecordDataType = associated_record_data_type
75
+ self.associatedRecordId = associated_record_id