labkey 2.6.1__tar.gz → 3.1.0__tar.gz

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.
Files changed (29) hide show
  1. {labkey-2.6.1 → labkey-3.1.0}/CHANGE.txt +22 -0
  2. {labkey-2.6.1/labkey.egg-info → labkey-3.1.0}/PKG-INFO +9 -3
  3. {labkey-2.6.1 → labkey-3.1.0}/README.md +1 -0
  4. {labkey-2.6.1 → labkey-3.1.0}/labkey/__init__.py +1 -1
  5. {labkey-2.6.1 → labkey-3.1.0}/labkey/api_wrapper.py +2 -0
  6. {labkey-2.6.1 → labkey-3.1.0}/labkey/exceptions.py +16 -1
  7. {labkey-2.6.1 → labkey-3.1.0}/labkey/query.py +182 -4
  8. {labkey-2.6.1 → labkey-3.1.0}/labkey/security.py +1 -1
  9. {labkey-2.6.1 → labkey-3.1.0}/labkey/server_context.py +27 -4
  10. {labkey-2.6.1 → labkey-3.1.0}/labkey/utils.py +20 -0
  11. {labkey-2.6.1 → labkey-3.1.0/labkey.egg-info}/PKG-INFO +9 -3
  12. {labkey-2.6.1 → labkey-3.1.0}/labkey.egg-info/SOURCES.txt +0 -4
  13. labkey-2.6.1/download_file_example.txt +0 -69
  14. labkey-2.6.1/lundbeck_file_download_notes.txt +0 -10
  15. labkey-2.6.1/perf_testing_notes.txt +0 -20
  16. labkey-2.6.1/playground.py.txt +0 -46
  17. {labkey-2.6.1 → labkey-3.1.0}/LICENSE.txt +0 -0
  18. {labkey-2.6.1 → labkey-3.1.0}/MANIFEST.in +0 -0
  19. {labkey-2.6.1 → labkey-3.1.0}/labkey/container.py +0 -0
  20. {labkey-2.6.1 → labkey-3.1.0}/labkey/domain.py +0 -0
  21. {labkey-2.6.1 → labkey-3.1.0}/labkey/experiment.py +0 -0
  22. {labkey-2.6.1 → labkey-3.1.0}/labkey/storage.py +0 -0
  23. {labkey-2.6.1 → labkey-3.1.0}/labkey.egg-info/dependency_links.txt +0 -0
  24. {labkey-2.6.1 → labkey-3.1.0}/labkey.egg-info/requires.txt +0 -0
  25. {labkey-2.6.1 → labkey-3.1.0}/labkey.egg-info/top_level.txt +0 -0
  26. {labkey-2.6.1 → labkey-3.1.0}/pyproject.toml +0 -0
  27. {labkey-2.6.1 → labkey-3.1.0}/pytest.ini +0 -0
  28. {labkey-2.6.1 → labkey-3.1.0}/setup.cfg +0 -0
  29. {labkey-2.6.1 → labkey-3.1.0}/setup.py +0 -0
@@ -2,6 +2,28 @@
2
2
  LabKey Python Client API News
3
3
  +++++++++++
4
4
 
5
+ What's New in the LabKey 3.1.0 package
6
+ ==============================
7
+
8
+ *Release date: 04/03/2024*
9
+ - ServerContext
10
+ - Add allow_redirects flag (defaults to False) to constructor
11
+ - Add allow_redirects flag to make_request
12
+ - APIWrapper: Add allow_redirects flag (defaults to False)
13
+ - Add UnexpectedRedirectError
14
+ - thrown when allow_redirects is False and the server issues a redirect
15
+
16
+ What's New in the LabKey 3.0.0 package
17
+ ==============================
18
+
19
+ *Release date: 12/14/2023*
20
+ - Query API - WAF encode "sql" parameter for execute_sql
21
+ - WAF encoding of parameters is initially supported with LabKey Server v23.09
22
+ - WAF encoding can be opted out of on execute_sql calls by specifying waf_encode_sql=False
23
+ - Query API - add optional parameters to insert_rows, update_rows, and delete_rows
24
+ - Query API - add move_rows()
25
+ - earliest compatible LabKey Server version: 24.1.0
26
+
5
27
  What's New in the LabKey 2.6.1 package
6
28
  ==============================
7
29
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: labkey
3
- Version: 2.6.1
3
+ Version: 3.1.0
4
4
  Summary: Python client API for LabKey Server
5
5
  Home-page: https://github.com/LabKey/labkey-api-python
6
6
  Author: LabKey
@@ -8,9 +8,7 @@ Author-email: alanv@labkey.com
8
8
  Maintainer: Alan Vezina
9
9
  Maintainer-email: alanv@labkey.com
10
10
  License: Apache License 2.0
11
- Description: Python client API for LabKey Server. Supports query and experiment APIs.
12
11
  Keywords: labkey api client
13
- Platform: UNKNOWN
14
12
  Classifier: Development Status :: 4 - Beta
15
13
  Classifier: Environment :: Console
16
14
  Classifier: Intended Audience :: Science/Research
@@ -21,4 +19,12 @@ Classifier: Operating System :: Microsoft
21
19
  Classifier: Operating System :: POSIX
22
20
  Classifier: Programming Language :: Python :: 3
23
21
  Classifier: Topic :: Scientific/Engineering
22
+ License-File: LICENSE.txt
23
+ Requires-Dist: requests
24
24
  Provides-Extra: test
25
+ Requires-Dist: pytest; extra == "test"
26
+ Requires-Dist: requests; extra == "test"
27
+ Requires-Dist: mock; extra == "test"
28
+ Requires-Dist: pytest-cov; extra == "test"
29
+
30
+ Python client API for LabKey Server. Supports query and experiment APIs.
@@ -16,6 +16,7 @@ Query API - [sample code](samples/query_examples.py)
16
16
  - **insert_rows()** - Insert rows into a table.
17
17
  - **select_rows()** - Query and get results sets.
18
18
  - **update_rows()** - Update rows in a table.
19
+ - **move_rows()()** - Move rows in a table.
19
20
  - **truncate_table()** - Delete all rows from a table.
20
21
 
21
22
  Domain API - [sample code](samples/domain_example.py)
@@ -14,6 +14,6 @@
14
14
  # limitations under the License.
15
15
  #
16
16
  __title__ = "labkey"
17
- __version__ = "2.6.1"
17
+ __version__ = "3.1.0"
18
18
  __author__ = "LabKey"
19
19
  __license__ = "Apache License 2.0"
@@ -22,6 +22,7 @@ class APIWrapper:
22
22
  verify_ssl=True,
23
23
  api_key=None,
24
24
  disable_csrf=False,
25
+ allow_redirects=False,
25
26
  ):
26
27
  self.server_context = ServerContext(
27
28
  domain=domain,
@@ -31,6 +32,7 @@ class APIWrapper:
31
32
  verify_ssl=verify_ssl,
32
33
  api_key=api_key,
33
34
  disable_csrf=disable_csrf,
35
+ allow_redirects=allow_redirects,
34
36
  )
35
37
  self.container = ContainerWrapper(self.server_context)
36
38
  self.domain = DomainWrapper(self.server_context)
@@ -47,7 +47,22 @@ class RequestError(exceptions.RequestException):
47
47
  self.message = "No response received"
48
48
 
49
49
  def __str__(self):
50
- return repr(self.message)
50
+ return str(self.message)
51
+
52
+
53
+ class UnexpectedRedirectError(RequestError):
54
+ default_msg = "Unexpected redirect occurred"
55
+
56
+ def __init__(self, server_response, **kwargs):
57
+ super().__init__(server_response, **kwargs)
58
+
59
+ location = server_response.headers.get("Location", "")
60
+
61
+ # If the server is redirecting from http to https the user probably has a misconfigured ServerContext with use_ssl=False
62
+ if server_response.url.startswith("http://") and location.startswith("https://"):
63
+ self.message = "Redirected from http to https, set use_ssl=True in your APIWrapper or ServerContext"
64
+ elif location != "":
65
+ self.message = f"Unexpected redirect to: {location}"
51
66
 
52
67
 
53
68
  class QueryNotFoundError(RequestError):
@@ -44,6 +44,7 @@ import functools
44
44
  from typing import List
45
45
 
46
46
  from .server_context import ServerContext
47
+ from .utils import waf_encode
47
48
 
48
49
  _default_timeout = 60 * 5 # 5 minutes
49
50
 
@@ -164,12 +165,25 @@ class QueryFilter:
164
165
  return "<QueryFilter [{} {} {}]>".format(self.column_name, self.filter_type, self.value)
165
166
 
166
167
 
168
+ class AuditBehavior:
169
+ """
170
+ Enum of different auditing levels
171
+ """
172
+
173
+ DETAILED = "DETAILED"
174
+ NONE = "NONE"
175
+ SUMMARY = "SUMMARY"
176
+
177
+
167
178
  def delete_rows(
168
179
  server_context: ServerContext,
169
180
  schema_name: str,
170
181
  query_name: str,
171
182
  rows: any,
172
183
  container_path: str = None,
184
+ transacted: bool = True,
185
+ audit_behavior: AuditBehavior = None,
186
+ audit_user_comment: str = None,
173
187
  timeout: int = _default_timeout,
174
188
  ):
175
189
  """
@@ -179,12 +193,25 @@ def delete_rows(
179
193
  :param query_name: table name to delete from
180
194
  :param rows: Set of rows to delete
181
195
  :param container_path: labkey container path if not already set in context
196
+ :param transacted: whether all of the updates should be done in a single transaction
197
+ :param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
198
+ :param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
182
199
  :param timeout: timeout of request in seconds (defaults to 30s)
183
200
  :return:
184
201
  """
185
202
  url = server_context.build_url("query", "deleteRows.api", container_path=container_path)
203
+
186
204
  payload = {"schemaName": schema_name, "queryName": query_name, "rows": rows}
187
205
 
206
+ if transacted is False:
207
+ payload["transacted"] = transacted
208
+
209
+ if audit_behavior is not None:
210
+ payload["auditBehavior"] = audit_behavior
211
+
212
+ if audit_user_comment is not None:
213
+ payload["auditUserComment"] = audit_user_comment
214
+
188
215
  return server_context.make_request(
189
216
  url,
190
217
  json=payload,
@@ -231,6 +258,7 @@ def execute_sql(
231
258
  parameters: dict = None,
232
259
  required_version: float = None,
233
260
  timeout: int = _default_timeout,
261
+ waf_encode_sql: bool = True,
234
262
  ):
235
263
  """
236
264
  Execute sql query against a LabKey server.
@@ -248,11 +276,12 @@ def execute_sql(
248
276
  :param parameters: parameter values to pass through to a parameterized query
249
277
  :param required_version: Api version of response
250
278
  :param timeout: timeout of request in seconds (defaults to 30s)
279
+ :param waf_encode_sql: WAF encode sql in request (defaults to True)
251
280
  :return:
252
281
  """
253
282
  url = server_context.build_url("query", "executeSql.api", container_path=container_path)
254
283
 
255
- payload = {"schemaName": schema_name, "sql": sql}
284
+ payload = {"schemaName": schema_name, "sql": waf_encode(sql) if waf_encode_sql else sql}
256
285
 
257
286
  if container_filter is not None:
258
287
  payload["containerFilter"] = container_filter
@@ -285,6 +314,10 @@ def insert_rows(
285
314
  query_name: str,
286
315
  rows: List[any],
287
316
  container_path: str = None,
317
+ skip_reselect_rows: bool = False,
318
+ transacted: bool = True,
319
+ audit_behavior: AuditBehavior = None,
320
+ audit_user_comment: str = None,
288
321
  timeout: int = _default_timeout,
289
322
  ):
290
323
  """
@@ -294,6 +327,10 @@ def insert_rows(
294
327
  :param query_name: table name to insert into
295
328
  :param rows: set of rows to insert
296
329
  :param container_path: labkey container path if not already set in context
330
+ :param skip_reselect_rows: whether the full detailed response for the insert can be skipped
331
+ :param transacted: whether all of the updates should be done in a single transaction
332
+ :param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
333
+ :param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
297
334
  :param timeout: timeout of request in seconds (defaults to 30s)
298
335
  :return:
299
336
  """
@@ -301,6 +338,18 @@ def insert_rows(
301
338
 
302
339
  payload = {"schemaName": schema_name, "queryName": query_name, "rows": rows}
303
340
 
341
+ if skip_reselect_rows is True:
342
+ payload["skipReselectRows"] = skip_reselect_rows
343
+
344
+ if transacted is False:
345
+ payload["transacted"] = transacted
346
+
347
+ if audit_behavior is not None:
348
+ payload["auditBehavior"] = audit_behavior
349
+
350
+ if audit_user_comment is not None:
351
+ payload["auditUserComment"] = audit_user_comment
352
+
304
353
  return server_context.make_request(
305
354
  url,
306
355
  json=payload,
@@ -419,6 +468,9 @@ def update_rows(
419
468
  query_name: str,
420
469
  rows: List[any],
421
470
  container_path: str = None,
471
+ transacted: bool = True,
472
+ audit_behavior: AuditBehavior = None,
473
+ audit_user_comment: str = None,
422
474
  timeout: int = _default_timeout,
423
475
  ):
424
476
  """
@@ -429,6 +481,9 @@ def update_rows(
429
481
  :param query_name: table name to update
430
482
  :param rows: Set of rows to update
431
483
  :param container_path: labkey container path if not already set in context
484
+ :param transacted: whether all of the updates should be done in a single transaction
485
+ :param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
486
+ :param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
432
487
  :param timeout: timeout of request in seconds (defaults to 30s)
433
488
  :return:
434
489
  """
@@ -436,6 +491,66 @@ def update_rows(
436
491
 
437
492
  payload = {"schemaName": schema_name, "queryName": query_name, "rows": rows}
438
493
 
494
+ if transacted is False:
495
+ payload["transacted"] = transacted
496
+
497
+ if audit_behavior is not None:
498
+ payload["auditBehavior"] = audit_behavior
499
+
500
+ if audit_user_comment is not None:
501
+ payload["auditUserComment"] = audit_user_comment
502
+
503
+ return server_context.make_request(
504
+ url,
505
+ json=payload,
506
+ timeout=timeout,
507
+ )
508
+
509
+
510
+ def move_rows(
511
+ server_context: ServerContext,
512
+ target_container_path: str,
513
+ schema_name: str,
514
+ query_name: str,
515
+ rows: any,
516
+ container_path: str = None,
517
+ transacted: bool = True,
518
+ audit_behavior: AuditBehavior = None,
519
+ audit_user_comment: str = None,
520
+ timeout: int = _default_timeout,
521
+ ):
522
+ """
523
+ Move a set of rows from the schema.query
524
+ :param server_context: A LabKey server context. See utils.create_server_context.
525
+ :param target_container_path: target labkey container path for the move
526
+ :param schema_name: schema of table
527
+ :param query_name: table name to move from
528
+ :param rows: Set of rows to move
529
+ :param container_path: source labkey container path if not already set in context
530
+ :param transacted: whether all of the updates should be done in a single transaction
531
+ :param audit_behavior: used to override the audit behavior for the update. See class query.AuditBehavior
532
+ :param audit_user_comment: used to provide a comment that will be attached to certain detailed audit log records
533
+ :param timeout: timeout of request in seconds (defaults to 30s)
534
+ :return:
535
+ """
536
+ url = server_context.build_url("query", "moveRows.api", container_path=container_path)
537
+
538
+ payload = {
539
+ "targetContainerPath": target_container_path,
540
+ "schemaName": schema_name,
541
+ "queryName": query_name,
542
+ "rows": rows,
543
+ }
544
+
545
+ if transacted is False:
546
+ payload["transacted"] = transacted
547
+
548
+ if audit_behavior is not None:
549
+ payload["auditBehavior"] = audit_behavior
550
+
551
+ if audit_user_comment is not None:
552
+ payload["auditUserComment"] = audit_user_comment
553
+
439
554
  return server_context.make_request(
440
555
  url,
441
556
  json=payload,
@@ -458,10 +573,21 @@ class QueryWrapper:
458
573
  query_name: str,
459
574
  rows: any,
460
575
  container_path: str = None,
576
+ transacted: bool = True,
577
+ audit_behavior: AuditBehavior = None,
578
+ audit_user_comment: str = None,
461
579
  timeout: int = _default_timeout,
462
580
  ):
463
581
  return delete_rows(
464
- self.server_context, schema_name, query_name, rows, container_path, timeout
582
+ self.server_context,
583
+ schema_name,
584
+ query_name,
585
+ rows,
586
+ container_path,
587
+ transacted,
588
+ audit_behavior,
589
+ audit_user_comment,
590
+ timeout,
465
591
  )
466
592
 
467
593
  @functools.wraps(truncate_table)
@@ -484,6 +610,7 @@ class QueryWrapper:
484
610
  parameters: dict = None,
485
611
  required_version: float = None,
486
612
  timeout: int = _default_timeout,
613
+ waf_encode_sql: bool = True,
487
614
  ):
488
615
  return execute_sql(
489
616
  self.server_context,
@@ -498,6 +625,7 @@ class QueryWrapper:
498
625
  parameters,
499
626
  required_version,
500
627
  timeout,
628
+ waf_encode_sql,
501
629
  )
502
630
 
503
631
  @functools.wraps(insert_rows)
@@ -507,10 +635,23 @@ class QueryWrapper:
507
635
  query_name: str,
508
636
  rows: List[any],
509
637
  container_path: str = None,
638
+ skip_reselect_rows: bool = False,
639
+ transacted: bool = True,
640
+ audit_behavior: AuditBehavior = None,
641
+ audit_user_comment: str = None,
510
642
  timeout: int = _default_timeout,
511
643
  ):
512
644
  return insert_rows(
513
- self.server_context, schema_name, query_name, rows, container_path, timeout
645
+ self.server_context,
646
+ schema_name,
647
+ query_name,
648
+ rows,
649
+ container_path,
650
+ skip_reselect_rows,
651
+ transacted,
652
+ audit_behavior,
653
+ audit_user_comment,
654
+ timeout,
514
655
  )
515
656
 
516
657
  @functools.wraps(select_rows)
@@ -566,8 +707,45 @@ class QueryWrapper:
566
707
  query_name: str,
567
708
  rows: List[any],
568
709
  container_path: str = None,
710
+ transacted: bool = True,
711
+ audit_behavior: AuditBehavior = None,
712
+ audit_user_comment: str = None,
569
713
  timeout: int = _default_timeout,
570
714
  ):
571
715
  return update_rows(
572
- self.server_context, schema_name, query_name, rows, container_path, timeout
716
+ self.server_context,
717
+ schema_name,
718
+ query_name,
719
+ rows,
720
+ container_path,
721
+ transacted,
722
+ audit_behavior,
723
+ audit_user_comment,
724
+ timeout,
725
+ )
726
+
727
+ @functools.wraps(move_rows)
728
+ def move_rows(
729
+ self,
730
+ target_container_path: str,
731
+ schema_name: str,
732
+ query_name: str,
733
+ rows: any,
734
+ container_path: str = None,
735
+ transacted: bool = True,
736
+ audit_behavior: AuditBehavior = None,
737
+ audit_user_comment: str = None,
738
+ timeout: int = _default_timeout,
739
+ ):
740
+ return move_rows(
741
+ self.server_context,
742
+ target_container_path,
743
+ schema_name,
744
+ query_name,
745
+ rows,
746
+ container_path,
747
+ transacted,
748
+ audit_behavior,
749
+ audit_user_comment,
750
+ timeout,
573
751
  )
@@ -279,7 +279,7 @@ def stop_impersonating(server_context: ServerContext):
279
279
  Stop impersonating a user while keeping the original user logged in.
280
280
  """
281
281
  url = server_context.build_url(LOGIN_CONTROLLER, "stopImpersonating.api")
282
- return server_context.make_request(url)
282
+ return server_context.make_request(url, allow_redirects=True)
283
283
 
284
284
 
285
285
  @dataclass
@@ -8,6 +8,7 @@ from labkey.exceptions import (
8
8
  QueryNotFoundError,
9
9
  ServerContextError,
10
10
  ServerNotFoundError,
11
+ UnexpectedRedirectError,
11
12
  )
12
13
 
13
14
  API_KEY_TOKEN = "apikey"
@@ -29,7 +30,8 @@ def handle_response(response, non_json_response=False):
29
30
  content=response.content,
30
31
  )
31
32
  return result
32
-
33
+ elif sc == 302:
34
+ raise UnexpectedRedirectError(response)
33
35
  elif sc == 401:
34
36
  raise RequestAuthorizationError(response)
35
37
  elif sc == 404:
@@ -62,6 +64,7 @@ class ServerContext:
62
64
  verify_ssl=True,
63
65
  api_key=None,
64
66
  disable_csrf=False,
67
+ allow_redirects=False,
65
68
  ):
66
69
  self._container_path = container_path
67
70
  self._context_path = context_path
@@ -70,6 +73,7 @@ class ServerContext:
70
73
  self._verify_ssl = verify_ssl
71
74
  self._api_key = api_key
72
75
  self._disable_csrf = disable_csrf
76
+ self.allow_redirects = allow_redirects
73
77
  self._session = requests.Session()
74
78
  self._session.headers.update({"User-Agent": f"LabKey Python API/{__version__}"})
75
79
 
@@ -174,7 +178,9 @@ class ServerContext:
174
178
  non_json_response: bool = False,
175
179
  file_payload: any = None,
176
180
  json: dict = None,
181
+ allow_redirects=False,
177
182
  ) -> any:
183
+ allow_redirects_ = allow_redirects or self.allow_redirects
178
184
  if self._api_key is not None:
179
185
  if self._session.headers.get(API_KEY_TOKEN) is not self._api_key:
180
186
  self._session.headers.update({API_KEY_TOKEN: self._api_key})
@@ -189,7 +195,13 @@ class ServerContext:
189
195
 
190
196
  try:
191
197
  if method == "GET":
192
- response = self._session.get(url, params=payload, headers=headers, timeout=timeout)
198
+ response = self._session.get(
199
+ url,
200
+ params=payload,
201
+ headers=headers,
202
+ timeout=timeout,
203
+ allow_redirects=allow_redirects_,
204
+ )
193
205
  else:
194
206
  if file_payload is not None:
195
207
  response = self._session.post(
@@ -198,6 +210,7 @@ class ServerContext:
198
210
  files=file_payload,
199
211
  headers=headers,
200
212
  timeout=timeout,
213
+ allow_redirects=allow_redirects_,
201
214
  )
202
215
  elif json is not None:
203
216
  if headers is None:
@@ -206,10 +219,20 @@ class ServerContext:
206
219
  headers_ = {**headers, "Content-Type": "application/json"}
207
220
  # sort_keys is a hack to make unit tests work
208
221
  data = json_dumps(json, sort_keys=True)
209
- response = self._session.post(url, data=data, headers=headers_, timeout=timeout)
222
+ response = self._session.post(
223
+ url,
224
+ data=data,
225
+ headers=headers_,
226
+ timeout=timeout,
227
+ allow_redirects=allow_redirects_,
228
+ )
210
229
  else:
211
230
  response = self._session.post(
212
- url, data=payload, headers=headers, timeout=timeout
231
+ url,
232
+ data=payload,
233
+ headers=headers,
234
+ timeout=timeout,
235
+ allow_redirects=allow_redirects_,
213
236
  )
214
237
  return handle_response(response, non_json_response)
215
238
  except RequestException as e:
@@ -16,6 +16,8 @@
16
16
  import json
17
17
  from functools import wraps
18
18
  from datetime import date, datetime
19
+ from base64 import b64encode
20
+ from urllib import parse
19
21
 
20
22
 
21
23
  # Issue #14: json.dumps on datetime throws TypeError
@@ -71,3 +73,21 @@ def transform_helper(user_transform_func, file_path_run_properties):
71
73
  row = [str(el).strip() for el in row]
72
74
  row = "\t".join(row)
73
75
  file_out.write(row + "\n")
76
+
77
+
78
+ def btoa(value: str) -> str:
79
+ if not value:
80
+ return value
81
+ binary = value.encode("utf-8")
82
+ return b64encode(binary).decode()
83
+
84
+
85
+ def encode_uri_component(value: str) -> str:
86
+ # https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
87
+ return parse.quote(value, encoding="utf-8", safe="-_.!~*'()")
88
+
89
+
90
+ def waf_encode(value: str) -> str:
91
+ if value:
92
+ return "/*{{base64/x-www-form-urlencoded/wafText}}*/" + btoa(encode_uri_component(value))
93
+ return value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: labkey
3
- Version: 2.6.1
3
+ Version: 3.1.0
4
4
  Summary: Python client API for LabKey Server
5
5
  Home-page: https://github.com/LabKey/labkey-api-python
6
6
  Author: LabKey
@@ -8,9 +8,7 @@ Author-email: alanv@labkey.com
8
8
  Maintainer: Alan Vezina
9
9
  Maintainer-email: alanv@labkey.com
10
10
  License: Apache License 2.0
11
- Description: Python client API for LabKey Server. Supports query and experiment APIs.
12
11
  Keywords: labkey api client
13
- Platform: UNKNOWN
14
12
  Classifier: Development Status :: 4 - Beta
15
13
  Classifier: Environment :: Console
16
14
  Classifier: Intended Audience :: Science/Research
@@ -21,4 +19,12 @@ Classifier: Operating System :: Microsoft
21
19
  Classifier: Operating System :: POSIX
22
20
  Classifier: Programming Language :: Python :: 3
23
21
  Classifier: Topic :: Scientific/Engineering
22
+ License-File: LICENSE.txt
23
+ Requires-Dist: requests
24
24
  Provides-Extra: test
25
+ Requires-Dist: pytest; extra == "test"
26
+ Requires-Dist: requests; extra == "test"
27
+ Requires-Dist: mock; extra == "test"
28
+ Requires-Dist: pytest-cov; extra == "test"
29
+
30
+ Python client API for LabKey Server. Supports query and experiment APIs.
@@ -2,10 +2,6 @@ CHANGE.txt
2
2
  LICENSE.txt
3
3
  MANIFEST.in
4
4
  README.md
5
- download_file_example.txt
6
- lundbeck_file_download_notes.txt
7
- perf_testing_notes.txt
8
- playground.py.txt
9
5
  pyproject.toml
10
6
  pytest.ini
11
7
  setup.cfg
@@ -1,69 +0,0 @@
1
- from labkey.api_wrapper import APIWrapper
2
-
3
-
4
- def get_base_url(api: APIWrapper) -> str:
5
- ctx = api.server_context
6
-
7
- # The URL returned from LabKey Server's select rows API isn't a full URL, so we need to add The scheme
8
- # (e.g. https://), the domain, and the context path. We don't need to add the container path, because that is
9
- # already on the URL returned from the server.
10
- base_url = ctx._scheme + ctx._domain
11
-
12
- if ctx._context_path is not None:
13
- base_url += "/" + ctx._context_path
14
-
15
- return base_url
16
-
17
-
18
- def get_file(api: APIWrapper, file_url: str):
19
- """
20
- Downloads a file given a file_url from a select_rows response. File is stored in memory and returned (as bytes).
21
- Response from this function could be passed to something like pandas. This is useful when you know the file is small
22
- enough to fit into memory, but will cause problems if you have a large file (see download_file below).
23
- """
24
- ctx = api.server_context
25
- full_url = get_base_url(api) + file_url
26
- resp = ctx._session.get(full_url)
27
- return resp.content
28
-
29
-
30
- def download_file(api: APIWrapper, file_name: str, file_url: str, destination_path: str):
31
- """
32
- Downloads a file from LabKey Server to disk. This doesn't put the whole file in memory, which is good for larger
33
- files. After the file is saved to disk you can open it with another tool such as pandas.
34
- """
35
- ctx = api.server_context
36
- full_url = get_base_url(api) + file_url
37
-
38
- # This with block is needed so we clean up the connection created by requests when we're done
39
- with ctx._session.get(full_url, stream=True) as req:
40
- # This with block opens and closes the file handle for us
41
- with open(destination_path + "/" + file_name, "wb") as f:
42
- # There is no standard correct size for chunk size here, you can play around with it and see if it has an
43
- # impact on perf.
44
- for chunk in req.iter_content(chunk_size=16*1024):
45
- f.write(chunk)
46
-
47
-
48
- def main():
49
- # Create your API wrapper, the variables here will depend on your server configuration
50
- domain = "localhost:8080"
51
- container = "api_sandbox"
52
- api = APIWrapper(domain, container, use_ssl=False, verify_ssl=False)
53
- # The name of the column that is a file, this will depend on the table you're querying
54
- file_column = "file"
55
-
56
- # Select your data, using required_version=17.1 is important here, older versions of the API return the file URLs,
57
- # but not in away that are associated with the actual file column.
58
- resp = api.query.select_rows("lists", "list of files", required_version=17.1)
59
-
60
- # Here we're just grabbing the first row of data, but you could easily iterate over all of the URLs. You could even
61
- # use a thread pool to load multiple files in parallel, which would improve perf because it's I/O bound.
62
- data = resp["rows"][0]["data"]
63
- file_name = data[file_column]["value"]
64
- file_url = data[file_column]["url"]
65
- download_file(api, file_name, file_url, "./downloads")
66
-
67
-
68
- if __name__ == "__main__":
69
- main()
@@ -1,10 +0,0 @@
1
- - make assay with file field in assay results
2
- - make python script to download said file
3
- - the crux here is converting the "url" attribute of the column in the select rows response to something
4
- that our labkey api can use to download the file
5
- - Storing the file in memory should be fine
6
- - Storing in /tmp may be better because this will be done in Docker, and if it's on disk it can
7
- be streamed which should theory be better perf-wise
8
-
9
-
10
-
@@ -1,20 +0,0 @@
1
- Run User Time System Time Total CPU Time Wall Time Total Rows Rows Retrieved
2
- 1 .42 .14 .56 1:36.6 118,000 118,000
3
- 2 .38 .12 .50 5:42.9 25,000,000 26,000
4
-
5
- Hypothesis:
6
- The server is taking a long time to respond to the request, which is causing the long response time. Both scripts spend
7
- very little time running, which means they're probably spending more time waiting for data from the server. I suspect
8
- that the R script may be faster because they're possibly running on the same machine as the server, so the network
9
- request stays local to the server, whereas the Python script is being run externally from the server, so the network is
10
- necessarily slower.
11
-
12
- Steps to reproduce:
13
- 1. Create some tables to emulate the query provided by the customer
14
- 2. Populate these tables with a similar size of data (25M total rows, that can be filtered to 26k rows)
15
- 3. Run the script locally with profiler.
16
-
17
-
18
- API time: 38.3407 seconds
19
- Pandas time: 1.5355 seconds
20
- Total time: 39.8762 seconds
@@ -1,46 +0,0 @@
1
- from labkey.query import QueryFilter
2
- from labkey.api_wrapper import APIWrapper
3
-
4
-
5
- def get_webdav_url(server_context, container_path=None):
6
- parts = [server_context._scheme + server_context._domain]
7
-
8
- if server_context._context_path is not None:
9
- parts.append(server_context._context_path)
10
-
11
- parts.append("_webdav")
12
-
13
- if container_path is not None:
14
- parts.append(container_path)
15
- elif server_context._container_path is not None:
16
- parts.append(server_context._container_path)
17
-
18
- parts.append("@files")
19
- parts.append("")
20
-
21
- return "/".join(parts)
22
-
23
-
24
- def main():
25
- # Create your API wrapper, the variables here will depend on your server configuration
26
- domain = "localhost:8080"
27
- container = "NIAD Python"
28
- api = APIWrapper(domain, container, use_ssl=False, verify_ssl=False)
29
- url = get_webdav_url(api.server_context)
30
- file_name = "api_wrapper.py"
31
- file_path = "./labkey/api_wrapper.py"
32
-
33
- # Open the file you want to upload, and upload it via the webdav API
34
- with open(file_path, 'r') as file:
35
- resp = api.server_context.make_request(url, payload={"createIntermediates": 'true'}, file_payload={"file": file}, non_json_response=True)
36
-
37
- # Find the RowId of the file we just uploaded, by using select_rows and filtering by the file name.
38
- resp = api.query.select_rows('exp', 'files', filter_array=[QueryFilter('name', file_name)])
39
- row_id = resp["rows"][0]["RowId"]
40
- # Update the file metadata, the "RowId" field here is required, the rest of the fields will depend on the custom fields you have defined
41
- resp = api.query.update_rows('exp', 'files', [{ "RowId": row_id, "Site": "My Test Site", "Visit": "A Visit", "Form": "Some Form" }])
42
- print(resp)
43
-
44
-
45
- if __name__ == "__main__":
46
- main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes