labdata 0.0.3__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.
labdata/widgets.py ADDED
@@ -0,0 +1,412 @@
1
+ from PyQt5.QtWidgets import (QApplication,
2
+ QTableWidget,
3
+ QTableWidgetItem,
4
+ QWidget,
5
+ QMainWindow,
6
+ QDockWidget,
7
+ QFormLayout,
8
+ QHBoxLayout,
9
+ QGridLayout,
10
+ QVBoxLayout,
11
+ QPushButton,
12
+ QGroupBox,
13
+ QGridLayout,
14
+ QTreeWidgetItem,
15
+ QTreeView,
16
+ QTextEdit,
17
+ QPlainTextEdit,
18
+ QLineEdit,
19
+ QScrollArea,
20
+ QCheckBox,
21
+ QComboBox,
22
+ QListWidget,
23
+ QLabel,
24
+ QProgressBar,
25
+ QFileDialog,
26
+ QMessageBox,
27
+ QDesktopWidget,
28
+ QListWidgetItem,
29
+ QFileSystemModel,
30
+ QAbstractItemView,
31
+ QTabWidget,
32
+ QMenu,
33
+ QDialog,
34
+ QDialogButtonBox,
35
+ QAction)
36
+
37
+ from PyQt5 import QtCore
38
+ from PyQt5.QtGui import QStandardItem, QStandardItemModel,QColor
39
+ from PyQt5.QtCore import Qt, QTimer,QMimeData
40
+ GUI_UPDATE = QApplication.processEvents
41
+
42
+ from .utils import *
43
+
44
+ class ServerCopyWidget(QMainWindow):
45
+ def __init__(self,
46
+ src_filepaths,
47
+ name = None,
48
+ upload_rule = None,
49
+ local_path = None,
50
+ server_path = None,
51
+ upload_storage = None,
52
+ subject_name = None,
53
+ session_name = None,
54
+ dataset_name = None,
55
+ parse_filename = True,
56
+ overwrite = True,
57
+ user_confirmation = True,
58
+ **kwargs):
59
+ '''Function to copy data and show progress.
60
+ To run in a separate process
61
+ '''
62
+ super(ServerCopyWidget,self).__init__()
63
+ from pathlib import Path
64
+ from PyQt5.QtWidgets import QListWidget, QListWidgetItem, QStyle
65
+
66
+ name = '[labdata] server copy {name}'
67
+ self.setWindowTitle(f'Copy for {name}')
68
+ icon = self.style().standardIcon(QStyle.SP_DialogSaveButton)
69
+ self.setWindowIcon(icon)
70
+
71
+ doneicon = self.style().standardIcon(QStyle.SP_DialogApplyButton)
72
+ notyeticon = self.style().standardIcon(QStyle.SP_DialogCancelButton)
73
+ workingonit = self.style().standardIcon(QStyle.SP_ArrowForward)
74
+
75
+ from .schema import UploadJob,Setup,Subject,Session,Dataset,dj
76
+ if local_path is None: # get the local_path from the preferences
77
+ if not 'local_paths' in prefs.keys():
78
+ raise OSError('Local data path [local_paths] not specified in the preference file.')
79
+ local_path = Path(prefs['local_paths'][0])
80
+ if server_path is None: # get the upload_path from the preferences
81
+ if not 'upload_path' in prefs.keys():
82
+ raise OSError('Upload storage [upload_path], not specified in the preference file.')
83
+ server_path = prefs['upload_path']
84
+ if server_path is None:
85
+ server_path = local_path
86
+ if upload_storage is None: # get the upload_storage name from the preferences
87
+ if not 'upload_storage' in prefs.keys():
88
+ raise OSError('Upload storage [upload_storage], not specified in the preference file.')
89
+ upload_storage = prefs['upload_storage']
90
+ if not type(src_filepaths) is list: # Check if the filepaths are in a list
91
+ raise ValueError('Input filepaths must be a list of paths.')
92
+ # replace local_path if the user passed it like that by accident.
93
+ src_filepaths = [str(Path(f)).replace(str(local_path),'') for f in src_filepaths]
94
+ # remove trailing / or \
95
+ src_filepaths = [f if not f.startswith(pathlib.os.sep) else f[1:] for f in src_filepaths]
96
+ # make unique
97
+ src_filepaths = [f for f in np.unique(src_filepaths)]
98
+
99
+ from .copy import any_path_uploaded
100
+ replace = False
101
+ if not upload_rule is None:
102
+ if 'replace' in upload_rule:
103
+ replace = True
104
+ if any_path_uploaded(src_filepaths) and not replace:
105
+ msgBox = QMessageBox( icon=QMessageBox.Critical)
106
+ msgBox.setWindowTitle("Data copy: files already copied?")
107
+ msgBox.setText('At least one of the selected paths was already uploaded {0}'.format(
108
+ Path(src_filepaths[0]).parent))
109
+ msgBox.exec_()
110
+ raise(OSError('At least one of the selected paths was already uploaded {0}'.format(
111
+ Path(src_filepaths[0]).parent)))
112
+
113
+ args = dict(**kwargs)
114
+ args['subject_name'] = subject_name
115
+ args['session_name'] = session_name
116
+ args['dataset_name'] = dataset_name
117
+ if parse_filename: # parse filename based on the path rules
118
+ tmp = parse_filepath_parts(src_filepaths[0])
119
+ for k in tmp.keys():
120
+ args[k] = tmp[k]
121
+
122
+ for src in src_filepaths:
123
+ src = Path(local_path)/src
124
+ if not src.exists():
125
+ msgBox = QMessageBox( icon=QMessageBox.Critical)
126
+ msgBox.setWindowTitle("Data copy: source does not exist!")
127
+ msgBox.setText("File {0} does not exist?".format(src))
128
+ msgBox.exec_()
129
+ raise(OSError(f"{tcolor['r']('Upload failed')} {src} does not exist."))
130
+ # Add it to the upload table
131
+ filelist = QListWidget()
132
+ self.files_to_copy = [Path(f) for f in src_filepaths]
133
+ file_items = []
134
+ itemnames = []
135
+ for i,f in enumerate(self.files_to_copy):
136
+ itemnames.append(str(f.name))
137
+ file_items.append(QListWidgetItem(notyeticon,itemnames[-1]))
138
+ # file_items[-1].setIcon(workingonit)
139
+ filelist.addItem(file_items[-1])
140
+ if user_confirmation:
141
+ msgBox = QMessageBox(icon = QMessageBox.Information)
142
+ msgBox.setWindowTitle("[labdata] user input required")
143
+ msgBox.setText(f"Copying {len(self.files_to_copy)} files to the server for upload to {upload_storage} storage.")
144
+ msgBox.setInformativeText("Do you want to continue?")
145
+ msgBox.setStandardButtons( QMessageBox.Cancel | QMessageBox.Save );
146
+ msgBox.setDefaultButton(QMessageBox.Save)
147
+ msgBox.setWindowFlags(Qt.WindowStaysOnTopHint)
148
+ res = msgBox.exec_()
149
+ if res == QMessageBox.Cancel:
150
+ print(tcolor['r']("User CANCELED the server upload."))
151
+ return
152
+ # copy and compute checksum for all paths in parallel.
153
+ from .copy import _copyfile_to_upload_server
154
+ res = Parallel(n_jobs = DEFAULT_N_JOBS,
155
+ return_as = 'generator_unordered')(delayed(_copyfile_to_upload_server)(
156
+ path,
157
+ local_path = local_path,
158
+ server_path = server_path,
159
+ overwrite = overwrite) for path in src_filepaths)
160
+ layout = QVBoxLayout(self)
161
+ # self.setLayout(layout)
162
+ layout.addWidget(filelist)
163
+ self.setCentralWidget(filelist)
164
+ # now dow the actual copy
165
+ self.show()
166
+ GUI_UPDATE()
167
+ # does the checksum and copy
168
+ completed = []
169
+ for i,src in enumerate(res):
170
+ GUI_UPDATE()
171
+ item = file_items[itemnames.index(src['src_path'].split('/')[-1])]
172
+ item.setIcon(doneicon)
173
+ completed.append(src)
174
+ GUI_UPDATE()
175
+ # Add it to the upload table
176
+ # check the job id
177
+ with dj.conn().transaction:
178
+ if "setup_name" in args.keys():
179
+ Setup.insert1(args, skip_duplicates = True,ignore_extra_fields = True) # try to insert setup
180
+ if "dataset_name" in args.keys() and "session_name" in args.keys() and "subject_name" in args.keys():
181
+ if not len(Subject() & dict(subject_name=args['subject_name'])):
182
+ # subject not on the database..
183
+ msgBox = QMessageBox(icon = QMessageBox.Information)
184
+ msgBox.setWindowTitle("[labdata] user input required")
185
+ msgBox.setText(f"Subject {args['subject_name']} was not on the database. ")
186
+ msgBox.setInformativeText("You can add the subject now or skip associating the session with a Dataset. Did you add the subject?")
187
+ msgBox.setStandardButtons( QMessageBox.No | QMessageBox.Yes );
188
+ msgBox.setDefaultButton(QMessageBox.No)
189
+ msgBox.setWindowFlags(Qt.WindowStaysOnTopHint)
190
+ #print(args)
191
+ res = msgBox.exec_()
192
+ if res == QMessageBox.No:
193
+ print(tcolor['r']("User selected not to add the Dataset but files are still uploaded."))
194
+ args = dict()
195
+ # Subject.insert1(args, skip_duplicates = True,ignore_extra_fields = True) # try to insert subject, needs date of birth and sex
196
+ if not len(Session() & dict(subject_name=args['subject_name'],
197
+ session_name = args['session_name'])):
198
+ Session.insert1(args, skip_duplicates = True,ignore_extra_fields = True) # try to insert session
199
+ if not len(Dataset() & dict(subject_name = args['subject_name'],
200
+ session_name = args['session_name'],
201
+ dataset_name = args['dataset_name'])):
202
+ Dataset.insert1(args, skip_duplicates = True,ignore_extra_fields = True) # try to insert dataset
203
+ jobid = UploadJob().fetch('job_id')
204
+ if len(jobid):
205
+ jobid = np.max(jobid) + 1
206
+ else:
207
+ jobid = 1
208
+ jb = dict(job_id = jobid,
209
+ job_status = "ON SERVER",
210
+ upload_storage = upload_storage,
211
+ job_rule = upload_rule,
212
+ **args)
213
+ if 'upload_host' in prefs.keys():
214
+ if not 'job_host' in jb.keys():
215
+ jb['job_host'] = prefs['upload_host']
216
+ UploadJob.insert1(jb,
217
+ ignore_extra_fields = True) # Need to insert the dataset first if not there
218
+ res = [dict(r, job_id = jobid) for r in completed] # add dataset through kwargs
219
+ UploadJob.AssignedFiles.insert(res, ignore_extra_fields=True)
220
+ import time
221
+ # show it for some time
222
+ for t in range(60):
223
+ time.sleep(0.15)
224
+ GUI_UPDATE()
225
+ print(f"{tcolor['g']('Upload:')} {completed}")
226
+ return
227
+
228
+ def build_tree(item,parent):
229
+ for k in item.keys():
230
+ child = QStandardItem(k)
231
+ child.setFlags(child.flags() |
232
+ Qt.ItemIsSelectable |
233
+ Qt.ItemIsEnabled)
234
+ child.setEditable(False)
235
+ if type(item[k]) is dict:
236
+ build_tree(item[k],child)
237
+ parent.appendRow(child)
238
+
239
+ def make_tree(item, tree):
240
+ if len(item) == 1:
241
+ if not item[0] == '':
242
+ tree[item[0]] = item[0]
243
+ else:
244
+ head, tail = item[0], item[1:]
245
+ tree.setdefault(head, {})
246
+ make_tree(
247
+ tail,
248
+ tree[head])
249
+ class TableModel(QtCore.QAbstractTableModel):
250
+
251
+ def __init__(self, data):
252
+ super(TableModel, self).__init__()
253
+ self._data = data
254
+
255
+ def data(self, index, role):
256
+ if role == Qt.DisplayRole:
257
+ value = self._data.iloc[index.row(), index.column()]
258
+ return str(value)
259
+
260
+ def rowCount(self, index):
261
+ return self._data.shape[0]
262
+
263
+ def columnCount(self, index):
264
+ return self._data.shape[1]
265
+
266
+ def headerData(self, section, orientation, role):
267
+ # section is the index of the column/row.
268
+ if role == Qt.DisplayRole:
269
+ if orientation == Qt.Horizontal:
270
+ return str(self._data.columns[section])
271
+
272
+ if orientation == Qt.Vertical:
273
+ return str(self._data.index[section])
274
+
275
+ class TableView(QTreeView):
276
+ def __init__(self, *args):
277
+ QTreeView.__init__(self, *args)
278
+ self.header = ['folder','n_files','subject_name','session_name','dataset_name']
279
+ self.setHorizontalHeaderLabels(self.header)
280
+
281
+ def setData(self,data):
282
+ self.data = data
283
+ model = TableModel(pd.DataFrame(data))
284
+
285
+ self.setModel(model)
286
+ # for i,item in enumerate(self.data):
287
+ # for j,k in enumerate(self.header):
288
+ # newitem = QTableWidgetItem(item[k])
289
+ # self.insertRow(i+1)
290
+ # self.setItem(i, j, newitem)
291
+ self.resizeColumnsToContents()
292
+ self.resizeRowsToContents()
293
+
294
+ def get_tree_path(items,root = ''):
295
+ ''' Get the paths from a QTreeView item'''
296
+ paths = []
297
+ for item in items:
298
+ level = 0
299
+ index = item
300
+ paths.append([index.data()])
301
+ while index.parent().isValid():
302
+ index = index.parent()
303
+ level += 1
304
+ paths[-1].append(index.data())
305
+ for i,p in enumerate(paths[-1]):
306
+ if p is None :
307
+ paths[-1][i] = ''
308
+ paths[-1] = '/'.join(paths[-1][::-1])
309
+ return paths
310
+
311
+ class FileView(QTreeView):
312
+ def __init__(self,prefs,parent=None):
313
+ super(FileView,self).__init__()
314
+ self.prefs = prefs
315
+ self.parent = parent
316
+ rootfolder = self.prefs['local_paths'][0]
317
+ self.fs_model = QFileSystemModel(self)
318
+ self.fs_model.setReadOnly(True)
319
+ self.setModel(self.fs_model)
320
+ self.folder = rootfolder
321
+ self.setRootIndex(self.fs_model.setRootPath(rootfolder))
322
+ self.fs_model.removeColumn(1)
323
+ self.setAlternatingRowColors(True)
324
+ self.setSelectionMode(3)
325
+ self.setDragEnabled(True)
326
+ self.setAcceptDrops(True)
327
+ self.setDragDropMode(QAbstractItemView.DragDrop)
328
+ self.setDropIndicatorShown(True)
329
+ [self.hideColumn(i) for i in range(1,4)]
330
+ self.setColumnWidth(0,int(self.width()*.4))
331
+ def pathnofolder(p):
332
+ return str(p).replace(rootfolder,'').strip(pathlib.os.sep)
333
+ def handle_click(val):
334
+ path = Path(get_tree_path([val])[0])
335
+ allfiles = list(filter(lambda x: x.is_file(),path.rglob('*')))
336
+ allfolders = np.unique(list(map(lambda x: x.parent,allfiles)))
337
+ to_upload = []
338
+ for f in allfolders:
339
+ f = str(f)
340
+ files = list(filter(lambda x: str(f) in str(x),list(allfiles)))
341
+ header = ['folder','n_files','subject_name','session_name','dataset_name','rule','paths']
342
+ ff = pathnofolder(f)
343
+ t = dict(folder = ff,
344
+ files = [pathnofolder(p) for p in files],
345
+ paths = files,
346
+ subject_name = None,
347
+ session_name = None,
348
+ dataset_name = None,
349
+ rule = None,
350
+ n_files = len(files))
351
+ to_upload.append(t)
352
+ self.parent.table.setData(to_upload)
353
+
354
+ # put this in the other side.
355
+ self.clicked.connect(handle_click)
356
+
357
+ class LABDATA_PUT(QMainWindow):
358
+ def __init__(self, preferences = None):
359
+ super(LABDATA_PUT,self).__init__()
360
+ self.setWindowTitle('labdata')
361
+ self.prefs = preferences
362
+ if self.prefs is None:
363
+ self.prefs = prefs
364
+ mainw = QWidget()
365
+ self.setCentralWidget(mainw)
366
+ lay = QHBoxLayout()
367
+ mainw.setLayout(lay)
368
+ # Add the main file view
369
+ self.table = TableView()
370
+ self.fs_view = FileView(self.prefs,parent=self)
371
+ lay.addWidget(self.fs_view)
372
+
373
+ w = QGroupBox('Database ingestion')
374
+
375
+ l = QFormLayout()
376
+ w.setLayout(l)
377
+ # self.skip_database = False
378
+ # skipwidget = QCheckBox()
379
+ # skipwidget.setChecked(self.skip_database)
380
+ # def _skipwidget(value):
381
+ # self.skip_database = value>0
382
+ # skipwidget.stateChanged.connect(_skipwidget)
383
+ # l.addRow(skipwidget,QLabel('Files only'))
384
+ l.addRow(self.table)
385
+ lay.addWidget(w)
386
+ self.show()
387
+
388
+ class FilesystemView(QTreeView):
389
+ def __init__(self,folder,parent=None):
390
+ super(FilesystemView,self).__init__()
391
+ self.parent = parent
392
+ self.fs_model = QFileSystemModel(self)
393
+ self.fs_model.setReadOnly(True)
394
+ self.setModel(self.fs_model)
395
+ self.folder = folder
396
+ self.setRootIndex(self.fs_model.setRootPath(folder))
397
+ #self.fs_model.removeColumn(1)
398
+ self.setAlternatingRowColors(True)
399
+ self.setSelectionMode(3)
400
+ self.setDragEnabled(False)
401
+ self.setAcceptDrops(False)
402
+ #self.setDragDropMode(QAbstractItemView.DragDrop)
403
+ #self.setDropIndicatorShown(True)
404
+ self.setColumnWidth(0,int(self.width()*.7))
405
+ self.expandAll()
406
+ def change_root(self):
407
+ folder = QFileDialog().getExistingDirectory(self,"Select directory",os.path.curdir)
408
+ self.setRootIndex(self.fs_model.setRootPath(folder))
409
+ self.expandAll()
410
+ self.folder = folder
411
+ if hasattr(self.parent,'folder'):
412
+ self.parent.folder.setText('{0}'.format(folder))
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: labdata
3
+ Version: 0.0.3
4
+ Summary: Package to manage data in experimental neuroscience labs
5
+ Author-email: Joao Couto <jpcouto@gmail.com>
6
+ Project-URL: Homepage, https://github.com/jcouto/labdata
7
+ Project-URL: Issues, https://github.com/jcouto/labdata/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.0
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: datajoint
14
+ Requires-Dist: boto3
15
+ Requires-Dist: safe-exit
16
+ Requires-Dist: joblib>=1.4.0
17
+ Requires-Dist: natsort
18
+ Requires-Dist: pandas
19
+ Dynamic: license-file
20
+
21
+ ## labdata
22
+
23
+ Tools to copy data and manage data analysis in an experimental neuroscience lab.
24
+
25
+ This is built after [labdata-tools](github.com/jcouto/labdata-tools) and integrates with S3 and a database through datajoint.
26
+
27
+ ### Concepts
28
+ Experiments often involve many computers in the lab and data has to be copied to a server(and sometimes backed up). In this process files can be corrupted, data loss and so on, specially if manually copying files. Further, in some cases, tasks have to be performed on the files (those might involve compressing, formatting, etc).
29
+
30
+ What ``labdata`` does to copy files:
31
+ 1. After data are acquired, labdata performs a md5 checksum of the file to be copied and copies the file to a server.
32
+ 2. After the copy, the checksum and datapath are placed on the ``Upload`` database table.
33
+ 3. The computer/server that manages the copy to the permanent storage server will read the database table. The server will perform a checksum on the file to ensure the copy from the acquisition computer was ok.
34
+ 4. If needed it will compress data or do whatever is specified in the preference file rules (see ``upload_rules``)
35
+ 5. Data will be put in S3 and added to the ``Files``
36
+
37
+
38
+ ### Instalation
39
+
40
+ Clone the repository to a folder in your computer and do:
41
+
42
+ ``pip install .`` or ``pip install -e .`` to if you want that the source code follows git.
@@ -0,0 +1,36 @@
1
+ labdata/__init__.py,sha256=4k4rk4Sn251VHbh6jhff6H7hpDqKyVfTRlQdnZ4qJOo,546
2
+ labdata/cli.py,sha256=aoDgs9X8quimzBe_BA1tuuQoACGbAizVOxQpe8nSR6w,22832
3
+ labdata/copy.py,sha256=G4pOI7tPtR8l_mr4qPGnTxG6_7cdQ3-c4JAm6Id6gHk,15100
4
+ labdata/s3.py,sha256=OIJzmu9QS3GCWjGMvQU3ceMN4amaynbMd2KVFxGaOBw,12322
5
+ labdata/stacks.py,sha256=1TGA790QXk40UWyfORvZ6lGpygu7TZHVqPyU0NYl11E,6487
6
+ labdata/utils.py,sha256=Cp6ToalHmEC85LGyqgatfO3_GY1lqO4oc8p0nu5ly2c,24537
7
+ labdata/widgets.py,sha256=5cgr4uynQzT80hHVaY6HQTNtU70e9VzJDdkk9_2ftOg,17988
8
+ labdata/compute/__init__.py,sha256=CT1kCqA3Yvu6Nz4Gjw1I98ot88g4qoGtTA9SlI5MMyE,915
9
+ labdata/compute/ec2.py,sha256=RzkkY9d9PVjJEsf8jxSLvuF3lKpXoAf7xjiuo9m2Geo,7486
10
+ labdata/compute/ephys.py,sha256=hbFWKHzLBnx-mLS_fgJpCqkBkGuvOs6sa6iddUqmWCo,25473
11
+ labdata/compute/pose.py,sha256=-P6XFz2OMgBgHpcxb5iaA9Peupas2BmS5yNrXr-ocno,14336
12
+ labdata/compute/schedulers.py,sha256=CV1RJWEYosQtCh6UN9Xm4SllNGzYlYo-9XixB7pPLZw,6886
13
+ labdata/compute/singularity.py,sha256=a2EgOqVVsPdpRap0rcjTOE2q2lo7XdnbWdIYZDUzhKw,3045
14
+ labdata/compute/utils.py,sha256=V746V-1cYemJ6z7VqC26kXfZqCGu18NkTj8XbuDpYng,28108
15
+ labdata/rules/__init__.py,sha256=DCSX9auO74TXUzskj1PXgE6i8rNijj2XUTJ59rNEDCg,2997
16
+ labdata/rules/ephys.py,sha256=pF5bGjjh1AhSUAuHZr7xFoX3EOE5KAWzssh9_yvPwow,9075
17
+ labdata/rules/imaging.py,sha256=wkmOzUOhSvqv8h4IZitKqVNh6b9Y8jWoi9HItHlHkXQ,28274
18
+ labdata/rules/utils.py,sha256=xwd1LOvWfbfT6U0_WHfowO9ELenh0ksq6GEU-6qx6kc,14358
19
+ labdata/schema/__init__.py,sha256=JDXQ7yRhwYri07js8YIQn3JzKQQaXMTP8qVYK4OydRY,903
20
+ labdata/schema/ephys.py,sha256=hAdtOtzv-Q4dl5FGIk0q0dY1KT30y30wi6cdNtbf7Ko,25169
21
+ labdata/schema/general.py,sha256=aRlvjFeyfgj2cWoe-tO6fW1R--Ro-nU3twgT6LVTg6c,26684
22
+ labdata/schema/histology.py,sha256=07ss5dPxphjfc9xWWvoPauT-F04_Cc_GYXvhCMAE76g,10287
23
+ labdata/schema/onephoton.py,sha256=U09hu40ZL-KKXIUaetplwos6Ha0OPKYmt-oSWs-ZWWs,3752
24
+ labdata/schema/procedures.py,sha256=eNTRmwVkaT2-Tavic1I-DwZQL77WQpFEPzrfQ1QJQU4,2590
25
+ labdata/schema/tasks.py,sha256=j2gV0x9nJokF48iUumZIjPx8l2lzppPpn0bsVfsfP_M,3486
26
+ labdata/schema/twophoton.py,sha256=K45RSQbZkJ2aMytUgV-t-QKTkCCZho9E6PzVJo1w83s,4932
27
+ labdata/schema/utils.py,sha256=TuYstTiYXs2h4PooPyhYPm6U01hIdx5U44LENlwuMk0,755
28
+ labdata/schema/video.py,sha256=8GkVfwGUvRQ9vG5gJzO6HcMXubD_Fd-gi0yqka56-VI,10849
29
+ labdata-0.0.3.dist-info/licenses/LICENSE,sha256=sfHVHGQbkFu5u5bGXuLD4DKwfvukIhPm_pzARealOik,35117
30
+ labdata_frontend/Home.py,sha256=QVRNxrGXfIRQM4-mzxY37Z966GImvRr9Tkv87tNKOwI,838
31
+ labdata_frontend/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
+ labdata-0.0.3.dist-info/METADATA,sha256=-bZ8YtE2GNnqmLg-jUGGLgPia8qSK0M88aDsfyW8R14,2017
33
+ labdata-0.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
34
+ labdata-0.0.3.dist-info/entry_points.txt,sha256=Qt1t66bAaQ3OkSa44Gj2bKc6mPx_tTbCNVomkhaXmbE,46
35
+ labdata-0.0.3.dist-info/top_level.txt,sha256=7PwSeFxIExHdXFPar_7GQ9L2sKQYD2Cm4OCQ0kNJDcI,25
36
+ labdata-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ labdata2 = labdata.cli:main