dcicutils 8.8.2__tar.gz → 8.8.2.1b2__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
Files changed (78) hide show
  1. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/PKG-INFO +1 -1
  2. dcicutils-8.8.2.1b2/dcicutils/progress_bar.py +281 -0
  3. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/structured_data.py +1 -1
  4. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/pyproject.toml +1 -1
  5. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/LICENSE.txt +0 -0
  6. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/README.rst +0 -0
  7. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/__init__.py +0 -0
  8. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/base.py +0 -0
  9. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/beanstalk_utils.py +0 -0
  10. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/bundle_utils.py +0 -0
  11. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/captured_output.py +0 -0
  12. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/cloudformation_utils.py +0 -0
  13. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/codebuild_utils.py +0 -0
  14. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/command_utils.py +0 -0
  15. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/common.py +0 -0
  16. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/contribution_scripts.py +0 -0
  17. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/contribution_utils.py +0 -0
  18. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/creds_utils.py +0 -0
  19. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/data_readers.py +0 -0
  20. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/data_utils.py +0 -0
  21. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/datetime_utils.py +0 -0
  22. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/deployment_utils.py +0 -0
  23. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/diff_utils.py +0 -0
  24. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/docker_utils.py +0 -0
  25. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/ecr_scripts.py +0 -0
  26. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/ecr_utils.py +0 -0
  27. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/ecs_utils.py +0 -0
  28. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/env_base.py +0 -0
  29. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/env_manager.py +0 -0
  30. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/env_scripts.py +0 -0
  31. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/env_utils.py +0 -0
  32. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/env_utils_legacy.py +0 -0
  33. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/es_utils.py +0 -0
  34. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/exceptions.py +0 -0
  35. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/ff_mocks.py +0 -0
  36. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/ff_utils.py +0 -0
  37. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/file_utils.py +0 -0
  38. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/function_cache_decorator.py +0 -0
  39. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/glacier_utils.py +0 -0
  40. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/jh_utils.py +0 -0
  41. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/kibana/dashboards.json +0 -0
  42. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/kibana/readme.md +0 -0
  43. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/lang_utils.py +0 -0
  44. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/license_policies/c4-infrastructure.jsonc +0 -0
  45. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/license_policies/c4-python-infrastructure.jsonc +0 -0
  46. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/license_policies/park-lab-common-server.jsonc +0 -0
  47. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/license_policies/park-lab-common.jsonc +0 -0
  48. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/license_policies/park-lab-gpl-pipeline.jsonc +0 -0
  49. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/license_policies/park-lab-pipeline.jsonc +0 -0
  50. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/license_utils.py +0 -0
  51. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/log_utils.py +0 -0
  52. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/misc_utils.py +0 -0
  53. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/obfuscation_utils.py +0 -0
  54. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/opensearch_utils.py +0 -0
  55. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/portal_object_utils.py +0 -0
  56. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/portal_utils.py +0 -0
  57. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/project_utils.py +0 -0
  58. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/qa_checkers.py +0 -0
  59. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/qa_utils.py +0 -0
  60. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/redis_tools.py +0 -0
  61. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/redis_utils.py +0 -0
  62. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/s3_utils.py +0 -0
  63. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/schema_utils.py +0 -0
  64. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/scripts/publish_to_pypi.py +0 -0
  65. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/scripts/run_license_checker.py +0 -0
  66. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/scripts/view_portal_object.py +0 -0
  67. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/secrets_utils.py +0 -0
  68. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/sheet_utils.py +0 -0
  69. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/snapshot_utils.py +0 -0
  70. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/ssl_certificate_utils.py +0 -0
  71. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/submitr/progress_constants.py +0 -0
  72. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/submitr/ref_lookup_strategy.py +0 -0
  73. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/task_utils.py +0 -0
  74. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/tmpfile_utils.py +0 -0
  75. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/trace_utils.py +0 -0
  76. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/validation_utils.py +0 -0
  77. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/variant_utils.py +0 -0
  78. {dcicutils-8.8.2 → dcicutils-8.8.2.1b2}/dcicutils/zip_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: dcicutils
3
- Version: 8.8.2
3
+ Version: 8.8.2.1b2
4
4
  Summary: Utility package for interacting with the 4DN Data Portal and other 4DN resources
5
5
  Home-page: https://github.com/4dn-dcic/utils
6
6
  License: MIT
@@ -0,0 +1,281 @@
1
+ from collections import namedtuple
2
+ from signal import signal, SIGINT
3
+ import sys
4
+ import threading
5
+ import time
6
+ from tqdm import tqdm
7
+ from types import FrameType as frame
8
+ from typing import Callable, Optional, Union
9
+ from contextlib import contextmanager
10
+ from dcicutils.command_utils import yes_or_no
11
+
12
+
13
+ class TQDM(tqdm):
14
+
15
+ """
16
+ def moveto(self, n):
17
+ # Hack/workaround for apparent tqdm bug where (for example) if we use this twice
18
+ # in a row, and we interrupt (CTRL-C) the first one and abort the task, then the
19
+ # output for the second usage gets a bit garble; on the wrong line and whatnot;
20
+ # somehow, state is stuck across usages; can't quite see how from the tqdm code.
21
+ # This is a bit worrying but so far no other deleterious effects observed.
22
+ # This looks maybe promising:
23
+ return
24
+ """
25
+
26
+ # Nevermind the above; found a more pointed solution from here:
27
+ # https://stackoverflow.com/questions/41707229/why-is-tqdm-printing-to-a-newline-instead-of-updating-the-same-line
28
+ # Why in the world would tqdm be maintaining state across instances?? Whatever, this fixes it.
29
+ def __init__(self, *args, **kwargs):
30
+ super()._instances.clear() if super()._instances else None
31
+ super().__init__(*args, **kwargs)
32
+
33
+
34
+ # Wrapper around tqdm command-line progress bar.
35
+ class ProgressBar:
36
+
37
+ @staticmethod
38
+ @contextmanager
39
+ def define(*args, **kwargs):
40
+ progress_bar = None
41
+ try:
42
+ progress_bar = ProgressBar(*args, **kwargs)
43
+ yield progress_bar
44
+ finally:
45
+ if progress_bar:
46
+ progress_bar.done()
47
+
48
+ def __init__(self, total: Optional[int] = None,
49
+ description: Optional[str] = None,
50
+ catch_interrupt: bool = True,
51
+ interrupt: Optional[Callable] = None,
52
+ interrupt_continue: Optional[Callable] = None,
53
+ interrupt_stop: Optional[Callable] = None,
54
+ interrupt_exit: bool = False,
55
+ interrupt_exit_message: Optional[Union[Callable, str]] = None,
56
+ interrupt_message: Optional[str] = None,
57
+ printf: Optional[Callable] = None,
58
+ tidy_output_hack: bool = True) -> None:
59
+ self._bar = None
60
+ self._disabled = False
61
+ self._done = False
62
+ self._printf = printf if callable(printf) else print
63
+ self._tidy_output_hack = (tidy_output_hack is True)
64
+ self._started = time.time()
65
+ self._stop_requested = False
66
+ # Interrupt handling. We do not do the actual (signal) interrupt setup
67
+ # in self._initialiaze as that could be called from a (sub) thread; and in
68
+ # Python we can only set a signal (SIGINT in our case) on the main thread.
69
+ self._catch_interrupt = (catch_interrupt is True)
70
+ self._interrupt = interrupt if callable(interrupt) else None
71
+ self._interrupt_continue = interrupt_continue if callable(interrupt_continue) else None
72
+ self._interrupt_stop = interrupt_stop if callable(interrupt_stop) else None
73
+ if interrupt_exit in [True, False]:
74
+ if not self._interrupt_stop:
75
+ self._interrupt_stop = lambda _: interrupt_exit
76
+ self._interrupt_exit = interrupt_exit
77
+ else:
78
+ self._interrupt_exit = None
79
+ self._interrupt_message = interrupt_message if isinstance(interrupt_message, str) else None
80
+ if isinstance(interrupt_exit_message, str):
81
+ self._interrupt_exit_message = lambda bar: interrupt_exit_message
82
+ elif isinstance(interrupt_exit_message, Callable):
83
+ self._interrupt_exit_message = interrupt_exit_message
84
+ else:
85
+ self._interrupt_exit_message = None
86
+ self._interrupt_handler = None
87
+ if self._catch_interrupt:
88
+ self._interrupt_handler = self._define_interrupt_handler()
89
+ if self._tidy_output_hack is True:
90
+ self._tidy_output_hack = self._define_tidy_output_hack()
91
+ self._total = total if isinstance(total, int) and total >= 0 else 0
92
+ self._description = self._format_description(description)
93
+ # self._initialize()
94
+
95
+ def _initialize(self) -> bool:
96
+ # Do not actually create the tqdm object unless/until we have a positive total.
97
+ if (self._bar is None) and (self._total > 0):
98
+ bar_format = "{l_bar}{bar}| {n_fmt}/{total_fmt} | {rate_fmt} | {elapsed}{postfix} | ETA: {remaining} "
99
+ self._bar = TQDM(total=self._total, desc=self._description,
100
+ dynamic_ncols=True, bar_format=bar_format, unit="", file=sys.stdout)
101
+ if self._disabled:
102
+ self._bar.disable = True
103
+ return True
104
+ return False
105
+
106
+ def set_total(self, value: int, reset_eta: bool = False) -> None:
107
+ if value == self._total:
108
+ # If the total has not changed since last set then do nothing.
109
+ if reset_eta and self._bar is not None:
110
+ self._bar.reset()
111
+ return
112
+ if isinstance(value, int) and value > 0:
113
+ self._total = value
114
+ if self._bar is not None:
115
+ # This reset is needed to get the ETA to reset properly when we reset
116
+ # the total during the course of a single ProgressBar instance.
117
+ self._bar.reset()
118
+ self._bar.total = value
119
+
120
+ def reset_eta(self) -> None:
121
+ # Since set_total does nothing if total is the same, provide
122
+ # a way to reset the ETA if starting over with the same total.
123
+ if self._bar is not None:
124
+ self._bar.reset()
125
+
126
+ def set_progress(self, value: int) -> None:
127
+ if isinstance(value, int) and value >= 0:
128
+ if (self._bar is not None) or self._initialize():
129
+ self._bar.n = value
130
+ self._bar.refresh()
131
+
132
+ def increment_progress(self, value: int) -> None:
133
+ if isinstance(value, int) and value > 0:
134
+ if (self._bar is not None) or self._initialize():
135
+ self._bar.update(value)
136
+ self._bar.refresh()
137
+
138
+ def set_description(self, value: str) -> None:
139
+ self._description = self._format_description(value)
140
+ if self._bar is not None:
141
+ self._bar.set_description(self._description)
142
+
143
+ def done(self) -> None:
144
+ if self._done or self._bar is None:
145
+ return
146
+ self._ended = time.time()
147
+ self._done = True
148
+ self.set_progress(self.total) # xyzzy
149
+ self._bar.set_description(self._description)
150
+ self._bar.refresh()
151
+ # FYI: Do NOT do a bar.disable = True before a bar.close() or it messes up output
152
+ # on multiple calls; found out the hard way; a couple hours will never get back :-/
153
+ self._bar.close()
154
+ if self._tidy_output_hack:
155
+ self._tidy_output_hack.restore()
156
+ if self._interrupt_handler:
157
+ self._interrupt_handler.restore()
158
+
159
+ def disable(self, value: bool = True) -> None:
160
+ self._disabled = (value is True)
161
+ if self._bar is not None:
162
+ self._bar.disable = self._disabled
163
+
164
+ def enable(self, value: bool = True) -> None:
165
+ self.disable(not value)
166
+
167
+ @property
168
+ def total(self) -> int:
169
+ return self._bar.total if self._bar else 0
170
+
171
+ @property
172
+ def progress(self) -> int:
173
+ return self._bar.n if self._bar else 0
174
+
175
+ @property
176
+ def disabled(self) -> bool:
177
+ return self._disabled
178
+
179
+ @property
180
+ def enabled(self) -> bool:
181
+ return not self.disabled
182
+
183
+ @property
184
+ def stop_requested(self) -> bool:
185
+ return self._stop_requested
186
+
187
+ @property
188
+ def started(self) -> None:
189
+ return self._started
190
+
191
+ @property
192
+ def duration(self) -> None:
193
+ return time.time() - self._started
194
+
195
+ def _format_description(self, value: str) -> str:
196
+ if not isinstance(value, str):
197
+ value = ""
198
+ if self._tidy_output_hack and not value.endswith(self._tidy_output_hack.sentinel):
199
+ value += self._tidy_output_hack.sentinel
200
+ return value
201
+
202
+ def _define_interrupt_handler(self) -> None:
203
+ def handle_interrupt(signum: int, frame: frame) -> None: # noqa
204
+ nonlocal self
205
+ def handle_secondary_interrupt(signum: int, frame: frame) -> None: # noqa
206
+ nonlocal self
207
+ self._printf("\nEnter 'yes' or 'no' or CTRL-\\ to completely abort ...")
208
+ self.disable()
209
+ self._interrupt(self) if self._interrupt else None
210
+ set_interrupt_handler(handle_secondary_interrupt)
211
+ if yes_or_no(f"\nALERT! You have interrupted this {self._interrupt_message or 'process'}."
212
+ f" Do you want to stop{' (exit)' if self._interrupt_exit else ''}?"):
213
+ # Here there was an interrupt (CTRL-C) and the user confirmed (yes)
214
+ # that they want to stop the process; if the interrupt_stop handler
215
+ # is defined and returns True, then we exit the entire process here,
216
+ # rather than simply returning, which is the default.
217
+ if self._interrupt_stop:
218
+ interrupt_stop = self._interrupt_stop(self)
219
+ if (interrupt_stop is True) or ((interrupt_stop is None) and (self._interrupt_exit is True)):
220
+ self.done()
221
+ restore_interrupt_handler()
222
+ if self._interrupt_exit_message:
223
+ if isinstance(interrupt_exit_message := self._interrupt_exit_message(self), str):
224
+ self._printf(interrupt_exit_message)
225
+ exit(1)
226
+ elif interrupt_stop is False or ((interrupt_stop is None) and (self._interrupt_exit is False)):
227
+ set_interrupt_handler(handle_interrupt)
228
+ interrupt_continue = self._interrupt_continue(self) if self._interrupt_continue else None
229
+ if not (interrupt_continue is False):
230
+ self.enable()
231
+ return
232
+ self._stop_requested = True
233
+ return
234
+ set_interrupt_handler(handle_interrupt)
235
+ self._interrupt_continue(self) if self._interrupt_continue else None
236
+ self.enable()
237
+ def restore_interrupt_handler() -> None: # noqa
238
+ nonlocal self, previous_interrupt_handler
239
+ set_interrupt_handler(previous_interrupt_handler)
240
+ def set_interrupt_handler(interrupt_handler: Callable) -> Optional[Callable]: # noqa
241
+ nonlocal self
242
+ if callable(interrupt_handler) and (threading.current_thread() == threading.main_thread()):
243
+ return signal(SIGINT, interrupt_handler)
244
+ return None
245
+ previous_interrupt_handler = set_interrupt_handler(handle_interrupt)
246
+ return namedtuple("interrupt_handler", ["restore"])(restore_interrupt_handler)
247
+
248
+ def _define_tidy_output_hack(self) -> None:
249
+ # Some minor tqdm output tidying-up which for annoying anomalies; tqdm forces
250
+ # a colon (:) before the percentage, e.g. ": 25%|"; and while we're at it do
251
+ # a little ASCII progress animation, requiring a special ([progress]) sentinal
252
+ # string in the display string where the progress bar should actually go,
253
+ # which we do in _format_description. Other minor things too; see below.
254
+ sys_stdout_write = sys.stdout.write
255
+ def tidy_stdout_write(text: str) -> None: # noqa
256
+ nonlocal self, sys_stdout_write, sentinel_internal, spina, spini, spinn
257
+ def replace_first(value: str, match: str, replacement: str) -> str: # noqa
258
+ return value[:i] + replacement + value[i + len(match):] if (i := value.find(match)) >= 0 else value
259
+ if (self._disabled or self._done) and sentinel_internal in text:
260
+ # Another hack to really disable output on interrupt; in this case we set
261
+ # tqdm.disable to True, but output can still dribble out, so if the output
262
+ # looks like it is from tqdm and we are disabled/done then do no output.
263
+ return
264
+ if sentinel_internal in text:
265
+ spinc = spina[spini % spinn] if not ("100%|" in text) else "| ✓" ; spini += 1 # noqa
266
+ text = replace_first(text, sentinel_internal, f" {spinc}")
267
+ text = replace_first(text, "%|", "% ◀|")
268
+ # Another oddity: for the rate sometimes tqdm intermittently prints
269
+ # something like "1.54s/" rather than "1.54/s"; something to do with
270
+ # the unit we gave, which is empty; idunno; just replace it here.
271
+ text = replace_first(text, "s/ ", "/s ")
272
+ sys_stdout_write(text)
273
+ sys.stdout.flush()
274
+ def restore_stdout_write() -> None: # noqa
275
+ nonlocal sys_stdout_write
276
+ if sys_stdout_write is not None:
277
+ sys.stdout.write = sys_stdout_write
278
+ sys.stdout.write = tidy_stdout_write
279
+ spina = ["|", "/", "—", "◦", "\\"] ; spini = 0 ; spinn = len(spina) # noqa
280
+ sentinel = "[progress]" ; sentinel_internal = f"{sentinel}:" # noqa
281
+ return namedtuple("tidy_output_hack", ["restore", "sentinel"])(restore_stdout_write, sentinel)
@@ -335,7 +335,7 @@ class StructuredDataSet:
335
335
  self._add(type_name, structured_row)
336
336
  if self._progress:
337
337
  self._progress({
338
- PROGRESS.LOAD_ITEM: True,
338
+ PROGRESS.LOAD_ITEM: self._nrows,
339
339
  PROGRESS.LOAD_COUNT_REFS: self.ref_total_count,
340
340
  PROGRESS.LOAD_COUNT_REFS_FOUND: self.ref_total_found_count,
341
341
  PROGRESS.LOAD_COUNT_REFS_NOT_FOUND: self.ref_total_notfound_count,
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "dcicutils"
3
- version = "8.8.2"
3
+ version = "8.8.2.1b2" # TODO: To become 8.8.3
4
4
  description = "Utility package for interacting with the 4DN Data Portal and other 4DN resources"
5
5
  authors = ["4DN-DCIC Team <support@4dnucleome.org>"]
6
6
  license = "MIT"
File without changes
File without changes