labdata 0.1.22__tar.gz → 0.1.24__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 (85) hide show
  1. {labdata-0.1.22 → labdata-0.1.24}/PKG-INFO +1 -1
  2. {labdata-0.1.22 → labdata-0.1.24}/labdata/__init__.py +2 -1
  3. {labdata-0.1.22 → labdata-0.1.24}/labdata/accounts.py +57 -3
  4. {labdata-0.1.22 → labdata-0.1.24}/labdata/cli.py +436 -5
  5. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/__init__.py +2 -0
  6. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/ec2.py +4 -4
  7. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/ephys.py +308 -126
  8. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/pcluster.py +8 -0
  9. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/schedulers.py +3 -1
  10. labdata-0.1.24/labdata/compute/spikeinterface.py +937 -0
  11. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/utils.py +47 -24
  12. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/compute.py +1 -1
  13. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/drift.py +12 -16
  14. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/sorting.py +31 -18
  15. labdata-0.1.24/labdata/export/__init__.py +15 -0
  16. labdata-0.1.24/labdata/export/database.py +198 -0
  17. labdata-0.1.24/labdata/export/docker/Dockerfile.in +37 -0
  18. labdata-0.1.24/labdata/export/docker/entrypoint.sh +75 -0
  19. labdata-0.1.24/labdata/export/docker/load_database.sh +25 -0
  20. labdata-0.1.24/labdata/export/docker/mysql.cnf +4 -0
  21. labdata-0.1.24/labdata/export/docker.py +319 -0
  22. labdata-0.1.24/labdata/export/selection.py +1034 -0
  23. labdata-0.1.24/labdata/export/share.py +197 -0
  24. labdata-0.1.24/labdata/export/users.py +160 -0
  25. {labdata-0.1.22 → labdata-0.1.24}/labdata/rules/ephys.py +241 -6
  26. {labdata-0.1.22 → labdata-0.1.24}/labdata/rules/utils.py +18 -12
  27. {labdata-0.1.22 → labdata-0.1.24}/labdata/s3.py +296 -139
  28. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/ephys.py +253 -73
  29. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/general.py +4 -3
  30. {labdata-0.1.22 → labdata-0.1.24}/labdata/utils.py +69 -8
  31. {labdata-0.1.22 → labdata-0.1.24}/labdata.egg-info/PKG-INFO +1 -1
  32. {labdata-0.1.22 → labdata-0.1.24}/labdata.egg-info/SOURCES.txt +11 -1
  33. {labdata-0.1.22 → labdata-0.1.24}/pyproject.toml +4 -1
  34. labdata-0.1.22/labdata/export.py +0 -381
  35. {labdata-0.1.22 → labdata-0.1.24}/LICENSE +0 -0
  36. {labdata-0.1.22 → labdata-0.1.24}/README.md +0 -0
  37. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/caiman.py +0 -0
  38. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/pose.py +0 -0
  39. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/singularity.py +0 -0
  40. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/submission.py +0 -0
  41. {labdata-0.1.22 → labdata-0.1.24}/labdata/compute/suite2p.py +0 -0
  42. {labdata-0.1.22 → labdata-0.1.24}/labdata/copy.py +0 -0
  43. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/__init__.py +0 -0
  44. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/activity.py +0 -0
  45. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/admin.py +0 -0
  46. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/ai_utils.py +0 -0
  47. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/console.py +0 -0
  48. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/decisiontask.py +0 -0
  49. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/docstrings.py +0 -0
  50. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/ephys.py +0 -0
  51. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/events.py +0 -0
  52. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/imaging.py +0 -0
  53. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/index.py +0 -0
  54. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/launch.py +0 -0
  55. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/local_files.py +0 -0
  56. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/notes.py +0 -0
  57. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/procedures.py +0 -0
  58. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/raster.py +0 -0
  59. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/reads.py +0 -0
  60. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/schema_graph.py +0 -0
  61. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/segmentation.py +0 -0
  62. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/sync.py +0 -0
  63. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/upload.py +0 -0
  64. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/upload_worker.py +0 -0
  65. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/video.py +0 -0
  66. {labdata-0.1.22 → labdata-0.1.24}/labdata/dashboard/writes.py +0 -0
  67. {labdata-0.1.22 → labdata-0.1.24}/labdata/ephys_io.py +0 -0
  68. {labdata-0.1.22 → labdata-0.1.24}/labdata/ephys_signal.py +0 -0
  69. {labdata-0.1.22 → labdata-0.1.24}/labdata/rules/__init__.py +0 -0
  70. {labdata-0.1.22 → labdata-0.1.24}/labdata/rules/imaging.py +0 -0
  71. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/__init__.py +0 -0
  72. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/histology.py +0 -0
  73. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/onephoton.py +0 -0
  74. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/procedures.py +0 -0
  75. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/tasks.py +0 -0
  76. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/twophoton.py +0 -0
  77. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/utils.py +0 -0
  78. {labdata-0.1.22 → labdata-0.1.24}/labdata/schema/video.py +0 -0
  79. {labdata-0.1.22 → labdata-0.1.24}/labdata/stacks.py +0 -0
  80. {labdata-0.1.22 → labdata-0.1.24}/labdata/widgets.py +0 -0
  81. {labdata-0.1.22 → labdata-0.1.24}/labdata.egg-info/dependency_links.txt +0 -0
  82. {labdata-0.1.22 → labdata-0.1.24}/labdata.egg-info/entry_points.txt +0 -0
  83. {labdata-0.1.22 → labdata-0.1.24}/labdata.egg-info/requires.txt +0 -0
  84. {labdata-0.1.22 → labdata-0.1.24}/labdata.egg-info/top_level.txt +0 -0
  85. {labdata-0.1.22 → labdata-0.1.24}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labdata
3
- Version: 0.1.22
3
+ Version: 0.1.24
4
4
  Summary: Package to manage data in experimental neuroscience labs
5
5
  Author-email: Joao Couto <jpcouto@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/jcouto/labdata
@@ -1,4 +1,5 @@
1
- VERSION = "0.1.22"
1
+ VERSION = "0.1.24"
2
+ __version__ = VERSION
2
3
 
3
4
  from .utils import *
4
5
  from .copy import copy_to_upload_server
@@ -78,13 +78,22 @@ def database_grants(name, projects, database_name='lab_data', sandbox=False):
78
78
  return [g.replace('%', '%%') for g in grants]
79
79
 
80
80
 
81
+ def split_host(host, port=3306):
82
+ '''Return ``(address, port)`` of a database host written ``address`` or ``address:port``;
83
+ ``port`` is used when the host has none.'''
84
+ address, _, number = str(host).strip().rpartition(':')
85
+ if address and number.isdigit():
86
+ return address, int(number)
87
+ return str(host).strip(), int(port)
88
+
89
+
81
90
  def ask_database_admin(host, default_user=None):
82
91
  '''Ask for the database admin login and return a pymysql connection.
83
92
 
84
93
  Parameters
85
94
  ----------
86
95
  host : str
87
- Database address.
96
+ Database address, ``address`` or ``address:port``.
88
97
  default_user : str, optional
89
98
  User offered as default.
90
99
 
@@ -96,7 +105,8 @@ def ask_database_admin(host, default_user=None):
96
105
  print(f'Database admin login for {host} (used for this command only, not saved).')
97
106
  user = input(f' Admin user [{default_user or ""}]: ').strip() or default_user
98
107
  password = getpass(' Admin password: ')
99
- return pymysql.connect(host=host, user=user, password=password, autocommit=True)
108
+ address, port = split_host(host)
109
+ return pymysql.connect(host=address, port=port, user=user, password=password, autocommit=True)
100
110
 
101
111
 
102
112
  def database_user_exists(conn, name):
@@ -147,6 +157,49 @@ def database_user_grants(conn, name):
147
157
  return [r[0] for r in _rows(conn, f"SHOW GRANTS FOR '{name}'@'%%'", ())]
148
158
 
149
159
 
160
+ def _grant_pattern(schema):
161
+ '''Return the regular expression of a schema name of a GRANT, where ``_`` and ``%`` are
162
+ wildcards unless escaped with ``\\``.'''
163
+ out, i = '', 0
164
+ while i < len(schema):
165
+ c = schema[i]
166
+ if c == '\\' and i + 1 < len(schema):
167
+ out += re.escape(schema[i + 1])
168
+ i += 2
169
+ continue
170
+ out += '.*' if c == '%' else '.' if c == '_' else re.escape(c)
171
+ i += 1
172
+ return re.compile(out)
173
+
174
+
175
+ def database_access(conn, databases):
176
+ '''Return ``{user: [databases]}`` for the database users with privileges other than
177
+ REFERENCES on ``databases``; ``'*'`` stands for a grant on every database. Users whose
178
+ grants can not be read are left out.'''
179
+ access = dict()
180
+ for user in list_database_users(conn):
181
+ try:
182
+ grants = database_user_grants(conn, user)
183
+ except Exception:
184
+ continue
185
+ for grant in grants:
186
+ m = re.match(r'GRANT (.+?) ON (\S+?)\.\S+ TO ', grant)
187
+ if not m:
188
+ continue
189
+ privileges = set(p.strip().upper() for p in m.group(1).split(','))
190
+ if privileges <= {'USAGE', 'REFERENCES'}:
191
+ continue
192
+ schema = m.group(2).strip('`')
193
+ if schema == '*':
194
+ access.setdefault(user, set()).add('*')
195
+ continue
196
+ pattern = _grant_pattern(schema)
197
+ for database in databases:
198
+ if pattern.fullmatch(database):
199
+ access.setdefault(user, set()).add(database)
200
+ return {user: sorted(names) for user, names in sorted(access.items())}
201
+
202
+
150
203
  def create_s3_user(session, name, groups=(), policies=(), like=None):
151
204
  '''Create the IAM user ``name`` with one access key, and the permissions of ``groups``, ``policies`` or user ``like``.
152
205
 
@@ -836,7 +889,8 @@ def grant_database_project(conn, name, project, database_name='lab_data'):
836
889
  def connect_database_admin(host, user, password):
837
890
  '''Return a pymysql connection for ``user``, after checking it may create users and grant permissions.'''
838
891
  import pymysql
839
- conn = pymysql.connect(host=host, user=user, password=password, autocommit=True)
892
+ address, port = split_host(host)
893
+ conn = pymysql.connect(host=address, port=port, user=user, password=password, autocommit=True)
840
894
  grants = ' '.join(r[0] for r in _rows(conn, 'SHOW GRANTS', ())).upper()
841
895
  if 'CREATE USER' not in grants and 'ALL PRIVILEGES ON *.*' not in grants:
842
896
  conn.close()
@@ -41,6 +41,10 @@ class CLI_parser():
41
41
  Create and manage the database and S3 accounts of lab members (admin use).
42
42
  access
43
43
  Manage what the members of a project can read in S3 (admin use).
44
+ share
45
+ Copy tables, with their parents and part tables, from one project to another (admin use).
46
+ export
47
+ Copy a selection to its own database, a dump folder or a Docker image (admin use).
44
48
  '''
45
49
  def __init__(self):
46
50
  parser = argparse.ArgumentParser(
@@ -74,6 +78,8 @@ Administration commands (don't run on experimental computers):
74
78
  {tcolor["r"]("cluster")} create|status <name> Creates or checks the AWS ParallelCluster
75
79
  {tcolor["r"]("account")} create|show|list|grant|revoke <name> Creates and manages database and S3 accounts
76
80
  {tcolor["r"]("access")} show|sync|share <project> Sets what a project's members can read in S3
81
+ {tcolor["r"]("share")} <table> --from <project> --to <project> Shares tables with another project of the lab
82
+ {tcolor["r"]("export")} <table> --docker <folder> Exports data to a database, dump or Docker image
77
83
 
78
84
  ''')
79
85
 
@@ -615,7 +621,9 @@ Administration commands (don't run on experimental computers):
615
621
  whose output goes to ``task_log`` and ``~/.labdata/logs``,
616
622
  instead of in the terminal.
617
623
  --queue
618
- Print the current job queue for all targets and exit.
624
+ Print the scheduler queue of each target, then the ``ComputeTask``
625
+ table by status with the last line of each task log, and exit.
626
+ With an analysis name, only the tasks of that analysis.
619
627
  --delete-computes
620
628
  Delete all ``ComputeTask`` entries for this analysis (careful).
621
629
  --
@@ -666,6 +674,8 @@ Available analysis are
666
674
  targets = np.unique([prefs['compute']['default_target']]+
667
675
  list(prefs['compute']['remotes'].keys()))
668
676
  for q in targets:
677
+ if q == 'local': # no scheduler queue, the tasks are in the table below
678
+ continue
669
679
  queue = check_queue(target = q)
670
680
  if queue is None:
671
681
  continue
@@ -678,7 +688,14 @@ Available analysis are
678
688
  cc = tcolor['g']
679
689
  else:
680
690
  cc = tcolor['r']
681
- print(f"\t {j['jobid']} {j['name']} {cc('running')} time: {tcolor['y'](j['time'])}\n\t\tpartition: {j['partition']} node: {j['nodelist(reason)']} ")
691
+ print(f"\t {j['jobid']} {j['name']} {cc(j['state'].lower())} time: {tcolor['y'](j['time'])}\n\t\tpartition: {j['partition']} node: {j['nodelist(reason)']} ")
692
+ # the ComputeTask table, optionally for one analysis: labdata run <analysis> --queue
693
+ qparser = argparse.ArgumentParser(add_help = False)
694
+ qparser.add_argument('analysis', nargs = '?', default = None)
695
+ qparser.add_argument('-p','--project', default = _get_project())
696
+ qargs, _ = qparser.parse_known_args(argum)
697
+ from .utils import load_project_schema
698
+ self._print_compute_tasks(load_project_schema(qargs.project), analysis = qargs.analysis)
682
699
  return
683
700
  args = parser.parse_args(argum)
684
701
  if '--delete-computes' in argum:
@@ -1220,6 +1237,9 @@ examples:
1220
1237
  --s3-like, --s3-group, --s3-policy
1221
1238
  Extra IAM permissions: copy the groups and policies of a user, or
1222
1239
  add groups and managed policies (names or ARNs).
1240
+ --database-name
1241
+ Lab database (``database.name``) of the projects, for a copy made with
1242
+ ``labdata share --database-name``. Default: the one of the preference file.
1223
1243
  --profile, --region
1224
1244
  AWS admin profile and region (asked when omitted).
1225
1245
  --dry-run
@@ -1246,6 +1266,7 @@ examples:
1246
1266
  labdata account create
1247
1267
  labdata account create <name> -p <project> --restore --no-sandbox
1248
1268
  labdata account grant <name> -p <other project>
1269
+ labdata account grant <name> -p other_project --database-name newlab # a copy in another lab database
1249
1270
  labdata account show <name>
1250
1271
  ''')
1251
1272
  parser.add_argument('action', action = 'store',
@@ -1276,13 +1297,17 @@ examples:
1276
1297
  help = 'AWS profile with rights to manage IAM (asked when omitted).')
1277
1298
  parser.add_argument('--region', action = 'store', default = None, type = str,
1278
1299
  help = 'AWS region (asked when omitted).')
1300
+ parser.add_argument('--database-name', action = 'store', default = None, type = str,
1301
+ help = 'Lab database (database.name) of the projects, e.g. a copy made with '
1302
+ '"labdata share --database-name" (default: the one of the preference file).')
1279
1303
  parser.add_argument('--dry-run', action = 'store_true', default = False,
1280
1304
  help = 'Print the plan and change nothing.')
1281
1305
  args = parser.parse_args(sys.argv[2:])
1282
1306
 
1283
1307
  from . import accounts
1284
1308
  host = prefs['database'].get('database.host')
1285
- database_name = prefs['database'].get('database.name', 'lab_data')
1309
+ database_name = args.database_name or prefs['database'].get('database.name', 'lab_data')
1310
+ self._account_database_name = database_name
1286
1311
  admin_user = prefs['database'].get('database.user')
1287
1312
 
1288
1313
  if args.action == 'list':
@@ -1756,9 +1781,13 @@ examples:
1756
1781
  def _write_account_file(self, name, projects, password, keys):
1757
1782
  '''Write the preference file of a user with their new credentials.'''
1758
1783
  from . import accounts
1784
+ base = dict(prefs)
1785
+ database_name = getattr(self, '_account_database_name', None)
1786
+ if database_name:
1787
+ base['database'] = dict(prefs['database'], **{'database.name': database_name})
1759
1788
  path = accounts.write_private_json(
1760
1789
  self._account_file(name),
1761
- accounts.new_user_preferences(prefs, name, projects, database_password = password, s3_keys = keys))
1790
+ accounts.new_user_preferences(base, name, projects, database_password = password, s3_keys = keys))
1762
1791
  print(f' ✓ wrote {path} (mode 600)')
1763
1792
  print(f' Give it to {name} as <their labdata folder>/user_preferences.json, then delete your copy.')
1764
1793
  if password is None:
@@ -1920,6 +1949,361 @@ examples:
1920
1949
  write_log(final = True) # the last lines, often the summary
1921
1950
  print('Pipe closed.')
1922
1951
 
1952
+ def _export_restriction(self, args):
1953
+ '''Return the restriction of -a/-s/-d as a list of dicts, or None.'''
1954
+ fields = [('subject_name', args.subject), ('session_name', args.session), ('dataset_name', args.datatype)]
1955
+ restriction = [dict()]
1956
+ for field, values in fields:
1957
+ if values:
1958
+ restriction = [dict(r, **{field: v}) for r in restriction for v in values]
1959
+ return None if restriction == [dict()] else restriction
1960
+
1961
+ def _export_common_arguments(self, parser):
1962
+ self._add_default_arguments(parser, level = 3)
1963
+ parser.add_argument('--plugin', nargs = '+', default = [],
1964
+ help = 'Plugins whose tables are seeds (restricted by -a/-s/-d where they have those fields).')
1965
+ parser.add_argument('--exclude', nargs = '+', default = None,
1966
+ help = 'Tables left out with their part tables (class names, e.g. ComputeTask Dataset.DataFiles).')
1967
+ parser.add_argument('--schema-map', nargs = '+', default = [],
1968
+ help = 'source=target names for databases that do not start with the lab database name.')
1969
+ parser.add_argument('-n', '--n-jobs', type = int, default = DEFAULT_N_JOBS,
1970
+ help = f'Copy threads (default {DEFAULT_N_JOBS}).')
1971
+ parser.add_argument('--dry-run', action = 'store_true', default = False,
1972
+ help = 'Print the tables, rows and estimated size; copy nothing.')
1973
+ parser.add_argument('--existing', default = 'ask', choices = ['ask', 'keep', 'update', 'replace'],
1974
+ help = 'Rows of the selection already in the target: ask (default), keep them, '
1975
+ 'update their values, or replace them (delete the exported rows and copy them again).')
1976
+ parser.add_argument('--host', default = None, help = 'Another MySQL server to copy to: address or address:port.')
1977
+ parser.add_argument('--port', default = 3306, type = int, help = 'Port of --host when it has none (default 3306).')
1978
+ parser.add_argument('--db-user', default = None,
1979
+ help = 'Database user: the login for --host, or the account created in the --docker image (asked when not given).')
1980
+ parser.add_argument('--db-password', default = None,
1981
+ help = 'Its password (asked when not given; better asked than typed in the shell history).')
1982
+
1983
+ def _target_login(self, args):
1984
+ '''Return the --host target with its login from --db-user/--db-password, asked when not given.'''
1985
+ from getpass import getpass
1986
+ from .accounts import split_host
1987
+ print(f'Database login for {args.host} (used for this command only, not saved).')
1988
+ user = args.db_user or input(' User: ').strip()
1989
+ password = args.db_password if args.db_password is not None else getpass(f' Password for {user}: ')
1990
+ host, port = split_host(args.host, args.port)
1991
+ return dict(host = host, port = port, user = user, password = password)
1992
+
1993
+ def _lab_server_login(self):
1994
+ '''Ask for the login that writes the copy on the lab server, unless the environment has one.
1995
+
1996
+ The login is checked, then handed to this process in the environment (password encrypted)
1997
+ before the schema is loaded; it is not saved and stays out of the shell history.
1998
+ '''
1999
+ if os.environ.get('LABDATA_DATABASE_USER') and os.environ.get('LABDATA_DATABASE_PASS'):
2000
+ return
2001
+ import pymysql
2002
+ from getpass import getpass
2003
+ host = os.environ.get('LABDATA_DATABASE_HOST') or prefs['database'].get('database.host')
2004
+ default = prefs['database'].get('database.user') or ''
2005
+ print(f'Database login for {host} that writes the copy (used for this command only, not saved).')
2006
+ user = input(f' User [{default}]: ').strip() or default
2007
+ password = getpass(f' Password for {user}: ')
2008
+ from .accounts import split_host
2009
+ address, port = split_host(host)
2010
+ try:
2011
+ pymysql.connect(host = address, port = port, user = user, password = password).close()
2012
+ except pymysql.err.OperationalError as err:
2013
+ print(tcolor['r'](f'Could not log in as {user}: {err.args[-1]}'))
2014
+ exit(1)
2015
+ os.environ['LABDATA_DATABASE_USER'] = user
2016
+ os.environ['LABDATA_DATABASE_PASS'] = 'encrypted:' + encrypt_string(password)
2017
+
2018
+ def _image_login(self, args):
2019
+ '''Return the database account of a --docker image from --db-user/--db-password, asked when not given.'''
2020
+ from getpass import getpass
2021
+ user = args.db_user or input('Database account of the image [labdata]: ').strip() or 'labdata'
2022
+ password = args.db_password
2023
+ if password is None:
2024
+ password = getpass(f' Password for {user} [leave empty to generate one]: ')
2025
+ return user, password or None
2026
+
2027
+ def _print_export_access(self, report, target = None):
2028
+ '''Print who can read the databases of an export and how to give an account access.'''
2029
+ from . import accounts
2030
+ databases, project = report.attrs.get('databases', []), report.attrs.get('project')
2031
+ database_name = report.attrs.get('database_name')
2032
+ server = None
2033
+ if target:
2034
+ server = target['host'] if int(target.get('port', 3306)) == 3306 else f"{target['host']}:{target['port']}"
2035
+ where = f'on {server}' if target else 'on this server'
2036
+ try:
2037
+ if target:
2038
+ from .export.selection import _open_connection
2039
+ conn = _open_connection(target)
2040
+ else:
2041
+ import datajoint as dj
2042
+ conn = dj.conn()
2043
+ access = accounts.database_access(conn, databases)
2044
+ except Exception as err:
2045
+ print(f'Could not read the database grants {where}: {err}')
2046
+ access = None
2047
+ if access is not None:
2048
+ everything = [u for u, names in access.items() if '*' in names]
2049
+ some = {u: names for u, names in access.items() if '*' not in names}
2050
+ print(f'\nAccess to {", ".join(databases)} {where} (database grants):')
2051
+ for user, names in some.items():
2052
+ print(f' {user}: {", ".join(names)}')
2053
+ if not some:
2054
+ print(' no account has grants on these databases.')
2055
+ if everything:
2056
+ print(f' and the accounts with grants on every database: {", ".join(everything)}')
2057
+ option = '' if database_name == prefs['database'].get('database.name') else f' --database-name {database_name}'
2058
+ print('\nTo give an account access to the project (database grants, Project.User and, '
2059
+ 'with an IAM user, the S3 group of the project):')
2060
+ if target:
2061
+ print(f' LABDATA_PREFERENCES=<preference file for {server}> '
2062
+ f'labdata account grant <name> -p {project}{option}')
2063
+ print(f' (labdata account uses the server of the preference file; '
2064
+ f'"labdata account create <name> -p {project}{option}" for a new account)')
2065
+ else:
2066
+ print(f' labdata account grant <name> -p {project}{option}')
2067
+ print(f' labdata account create <name> -p {project}{option} # a new account')
2068
+
2069
+ def _print_export_report(self, report):
2070
+ copied = report[report['rows'].fillna(0) > 0]
2071
+ mode = report.attrs.get('existing', 'keep')
2072
+ for row in copied.itertuples():
2073
+ existing = int(row.existing or 0) if 'existing' in report else 0
2074
+ note = f', {existing} were there ({mode})' if existing else ''
2075
+ print(f'\t{row.table} {int(row.rows)} rows{note}')
2076
+ print(tcolor['g'](f"Done: {int(copied['rows'].sum())} rows in {len(copied)} tables."))
2077
+
2078
+ def share(self):
2079
+ '''Copy tables from one project to another, with their parents and part tables.
2080
+
2081
+ The lab-wide tables (File, LabMember, Setup, ...) are shared by the projects and are not
2082
+ copied. The copy runs on the database server. Needs read access to the source project and
2083
+ write access to the target databases.
2084
+
2085
+ Usage
2086
+ -----
2087
+ ::
2088
+
2089
+ labdata share EphysRecording --from couto_arc --to other_project -a JC1013
2090
+ labdata share EphysRecording --from couto_arc --to other_project --descendants --dry-run
2091
+ labdata share --plugin ratetask --from couto_arc --to other_project
2092
+ labdata share EphysRecording --from couto_arc --host 10.0.0.5 --db-user admin -a JC1013
2093
+ labdata share Dataset.DataFiles --from couto_arc --to other_project --database-name newlab -a JC1013
2094
+
2095
+ With --host the project goes to another MySQL server (same project name unless --to), with
2096
+ the lab-wide rows it uses (File, LabMember, ...); the password is asked. With a new
2097
+ --database-name on this server it goes to the databases ``newlab`` and
2098
+ ``newlab_<project>...``, with the lab-wide rows it uses. The preference entries that open
2099
+ the copy are written to a JSON file (--access-file).
2100
+ '''
2101
+ parser = argparse.ArgumentParser(
2102
+ description = 'Share tables, with their parents and part tables, with another project.',
2103
+ usage = 'labdata share <table ...> --from <project> --to <project> [-a subject] [-s session] [options]',
2104
+ formatter_class = argparse.RawDescriptionHelpFormatter,
2105
+ epilog = '''examples:
2106
+ labdata share EphysRecording --from couto_arc --to other_project -a JC1013 --dry-run
2107
+ labdata share EphysRecording --from couto_arc --to other_project --descendants # with spike sorting
2108
+ labdata share --plugin ratetask --from couto_arc --to other_project -a JC1013
2109
+ labdata share EphysRecording --from couto_arc --host 10.0.0.5 --db-user admin -a JC1013
2110
+
2111
+ # the File rows of the datasets of JC1013 (with their Session and Dataset rows), on this
2112
+ # server, to the lab-wide database newlab and the project databases newlab_other_project...
2113
+ labdata share Dataset.DataFiles --from couto_arc --to other_project --database-name newlab -a JC1013
2114
+
2115
+ The lab-wide database (File, LabMember, Setup, ...) is shared by the projects of a server and is
2116
+ not copied, unless --host or a new --database-name is given; then only the rows the selection
2117
+ uses are copied. Use a table with the -a/-s/-d fields as seed (File has none, so -a/-s/-d would
2118
+ not restrict it).
2119
+
2120
+ After the copy, the entries that open the target are written to a JSON file (--access-file,
2121
+ default labdata_access_<database name>_<project>.json): copy them into the "database" section of
2122
+ the preference file, or set them with LABDATA_DATABASE_PROJECT for the project.
2123
+ ''')
2124
+ parser.add_argument('table', nargs = '*', default = [],
2125
+ help = 'Seed tables (class names, e.g. EphysRecording, SpikeSorting).')
2126
+ parser.add_argument('--from', dest = 'source', required = True, help = 'Source project.')
2127
+ parser.add_argument('--to', dest = 'target', default = None,
2128
+ help = 'Target project (default with --host: the source project).')
2129
+ parser.add_argument('--database-name', default = None,
2130
+ help = 'database.name of the lab in the target (default: the same as here). On this server, '
2131
+ 'a new name copies the lab-wide rows the selection uses to that database.')
2132
+ parser.add_argument('--access-file', default = None,
2133
+ help = 'JSON file with the preference entries that open the target '
2134
+ '(default: labdata_access_<database name>_<project>.json).')
2135
+ parser.add_argument('--descendants', action = 'store_true', default = False,
2136
+ help = 'Also copy the tables derived from the seeds (spike sorting, ...).')
2137
+ self._export_common_arguments(parser)
2138
+ args = parser.parse_args(sys.argv[2:])
2139
+ if not args.table and not args.plugin:
2140
+ parser.error('give a table or --plugin.')
2141
+ if args.target is None and args.host is None and args.database_name is None:
2142
+ parser.error('give --to, --host or --database-name.')
2143
+ target = None
2144
+ if args.host is not None and not args.dry_run:
2145
+ target = self._target_login(args)
2146
+ elif args.host is not None:
2147
+ from .accounts import split_host
2148
+ address, port = split_host(args.host, args.port)
2149
+ target = dict(host = address, port = port)
2150
+ elif not args.dry_run:
2151
+ self._lab_server_login()
2152
+ from .export import share_project, DEFAULT_EXCLUDE, ExportCancelled
2153
+ try:
2154
+ report = share_project(args.table or None, args.source, args.target,
2155
+ target = target, database_name = args.database_name, existing = args.existing,
2156
+ restriction = self._export_restriction(args), plugins = args.plugin,
2157
+ descendants = args.descendants,
2158
+ exclude = DEFAULT_EXCLUDE if args.exclude is None else args.exclude,
2159
+ schema_map = dict(m.split('=', 1) for m in args.schema_map),
2160
+ n_jobs = args.n_jobs, dry_run = args.dry_run)
2161
+ except ExportCancelled as err:
2162
+ print(err)
2163
+ return
2164
+ except ValueError as err:
2165
+ print(tcolor['r'](str(err)))
2166
+ exit(1)
2167
+ if not args.dry_run:
2168
+ self._print_export_report(report)
2169
+ from .export import project_access
2170
+ host = None
2171
+ if target is not None:
2172
+ host = target['host'] if target['port'] == 3306 else f"{target['host']}:{target['port']}"
2173
+ access = project_access(args.target or args.source, args.database_name, host)
2174
+ name = access['database']['database.name']
2175
+ path = Path(args.access_file or f'labdata_access_{name}_{access["database"]["database.project"]}.json')
2176
+ with open(path, 'w') as fd:
2177
+ json.dump(access, fd, indent = 4)
2178
+ print(f'Preference entries to open {name} / {access["database"]["database.project"]} in {path}; '
2179
+ 'copy them into the "database" section of the preference file.')
2180
+ self._print_export_access(report, target)
2181
+
2182
+ def export(self):
2183
+ '''Copy a selection, with the lab-wide rows it uses, to its own database, a dump or a Docker image.
2184
+
2185
+ Derived tables are included unless --no-descendants. Raw files stay where they are unless
2186
+ --files bucket (copied to another storage) or --files download (to a folder).
2187
+
2188
+ Usage
2189
+ -----
2190
+ ::
2191
+
2192
+ labdata export EphysRecording -p couto_arc -a JC1013 --dry-run
2193
+ labdata export EphysRecording -p couto_arc -a JC1013 --database-name release_2026
2194
+ labdata export EphysRecording -p couto_arc --host 10.0.0.5 --db-user admin
2195
+ labdata export EphysRecording -p couto_arc --dump release_dump
2196
+ labdata export EphysRecording -p couto_arc --docker release --tag lab/release:1 \\
2197
+ --pip git+https://github.com/org/package --blank-personal --build
2198
+ labdata export EphysRecording -p couto_arc --docker release --link-data --dump release_data \\
2199
+ --tag lab/release:1 --build
2200
+
2201
+ Run the image with ``docker run -p 8888:8888 <tag>`` (Jupyter) or
2202
+ ``docker run -p 8501:8501 <tag> dashboard`` (dashboard). With --link-data the rows are not
2203
+ in the image: mount the data folder with ``-v <data folder>:/dump:ro`` and keep the loaded
2204
+ database in a volume with ``-v <volume>:/var/lib/mysql``.
2205
+ '''
2206
+ parser = argparse.ArgumentParser(
2207
+ description = 'Export a selection to a database, a dump folder or a Docker image.',
2208
+ usage = 'labdata export <table ...> [-p project] [-a subject] [--database-name name] '
2209
+ '[--host h --user u | --dump folder | --docker folder --tag tag [--link-data]] [options]')
2210
+ parser.add_argument('table', nargs = '*', default = [],
2211
+ help = 'Seed tables (class names, e.g. EphysRecording).')
2212
+ project = _get_project()
2213
+ parser.add_argument('-p', '--project', default = project, type = str, help = f'Source project: {project}')
2214
+ parser.add_argument('--database-name', default = None,
2215
+ help = 'New database.name of the exported copy (required on the lab server).')
2216
+ parser.add_argument('--dump', default = None,
2217
+ help = 'Write gzipped SQL files to this folder (with --docker --link-data: the data folder).')
2218
+ parser.add_argument('--docker', default = None, help = 'Write a Docker build context to this folder.')
2219
+ parser.add_argument('--link-data', action = 'store_true', default = False,
2220
+ help = 'With --docker: leave the rows out of the image; they go to a separate data folder '
2221
+ '(--dump, default <docker folder>_data) mounted at /dump when the container starts.')
2222
+ parser.add_argument('--tag', default = None, help = 'Image tag (with --docker).')
2223
+ parser.add_argument('--build', action = 'store_true', default = False, help = 'Run docker build (with --docker).')
2224
+ parser.add_argument('--platform', default = None, help = 'docker build --platform, e.g. linux/amd64.')
2225
+ parser.add_argument('--pip', nargs = '+', default = [],
2226
+ help = 'Packages to install in the image (PyPI names or git+https:// URLs).')
2227
+ parser.add_argument('--requirements', default = None, help = 'Requirements file to install in the image.')
2228
+ parser.add_argument('--labdata-source', default = None,
2229
+ help = 'pip source of labdata for the image (default: the code running now).')
2230
+ parser.add_argument('--no-dashboard', action = 'store_true', default = False,
2231
+ help = 'Leave the dashboard dependencies out of the image.')
2232
+ parser.add_argument('--pref', nargs = '+', default = [],
2233
+ help = 'key=value entries for the preference file of the image (value read as JSON when it parses).')
2234
+ parser.add_argument('--overwrite', action = 'store_true', default = False,
2235
+ help = 'Replace an existing --docker folder (and its data folder with --link-data).')
2236
+ parser.add_argument('--files', default = 'none', choices = ['none', 'bucket', 'download'],
2237
+ help = 'Raw files: leave them (none), copy them to --files-storage (bucket) or to --files-path (download).')
2238
+ parser.add_argument('--files-storage', default = None, help = 'Storage name of the preference file for --files bucket.')
2239
+ parser.add_argument('--files-path', default = None, help = 'Folder for --files download.')
2240
+ parser.add_argument('--blank-personal', action = 'store_true', default = False,
2241
+ help = 'Export LabMember rows with email, first and last name set to NULL.')
2242
+ parser.add_argument('--strip', nargs = '+', default = [],
2243
+ help = 'Further nullable columns to export as NULL: Table.column (e.g. Subject.subject_dob).')
2244
+ parser.add_argument('--no-descendants', action = 'store_true', default = False,
2245
+ help = 'Leave out the tables derived from the seeds.')
2246
+ self._export_common_arguments(parser)
2247
+ args = parser.parse_args(sys.argv[2:])
2248
+ if not args.table and not args.plugin:
2249
+ parser.error('give a table or --plugin.')
2250
+ if args.link_data and args.docker is None:
2251
+ parser.error('--link-data is used with --docker.')
2252
+ if args.host is not None and (args.dump is not None or args.docker is not None):
2253
+ parser.error('use one of --host, --dump or --docker.')
2254
+ if args.dump is not None and args.docker is not None and not args.link_data:
2255
+ parser.error('use one of --dump or --docker, or both with --link-data (the dump is the data folder).')
2256
+ from .export import export, EXPORT_EXCLUDE, ExportCancelled
2257
+ strip = dict()
2258
+ for item in args.strip:
2259
+ table, column = item.rsplit('.', 1)
2260
+ strip.setdefault(table, []).append(column)
2261
+ prefs_extra = dict()
2262
+ for item in args.pref:
2263
+ key, value = item.split('=', 1)
2264
+ try:
2265
+ prefs_extra[key] = json.loads(value)
2266
+ except ValueError:
2267
+ prefs_extra[key] = value
2268
+ options = dict(project = args.project, restriction = self._export_restriction(args), plugins = args.plugin,
2269
+ database_name = args.database_name, descendants = not args.no_descendants,
2270
+ exclude = EXPORT_EXCLUDE if args.exclude is None else args.exclude,
2271
+ schema_map = dict(m.split('=', 1) for m in args.schema_map),
2272
+ blank_personal = args.blank_personal, strip_columns = strip,
2273
+ files = args.files, files_storage = args.files_storage, files_path = args.files_path,
2274
+ existing = args.existing, n_jobs = args.n_jobs, dry_run = args.dry_run)
2275
+ if args.docker is not None:
2276
+ from .export import build_export_image
2277
+ db_user, db_password = ('labdata', None) if args.dry_run else self._image_login(args)
2278
+ options.pop('existing') # an image is built from a new dump folder
2279
+ result = build_export_image(args.table or None, args.docker, tag = args.tag, pip = args.pip,
2280
+ db_user = db_user, db_password = db_password,
2281
+ requirements = args.requirements, labdata_source = args.labdata_source,
2282
+ dashboard = not args.no_dashboard, platform = args.platform,
2283
+ build = args.build, prefs_extra = prefs_extra,
2284
+ link_data = args.link_data, data_dir = args.dump,
2285
+ overwrite = args.overwrite, **options)
2286
+ else:
2287
+ target = None
2288
+ if args.host is not None and not args.dry_run:
2289
+ target = self._target_login(args)
2290
+ elif args.dump is not None:
2291
+ target = args.dump
2292
+ elif not args.dry_run:
2293
+ self._lab_server_login()
2294
+ try:
2295
+ result = export(args.table or None, target, **options)
2296
+ except ExportCancelled as err:
2297
+ print(err)
2298
+ return
2299
+ except ValueError as err:
2300
+ print(tcolor['r'](str(err)))
2301
+ exit(1)
2302
+ if not args.dry_run:
2303
+ self._print_export_report(result['report'])
2304
+ if args.docker is not None and not args.build:
2305
+ print(f"Build the image with: docker build -t {args.tag or '<tag>'} {args.docker}")
2306
+
1923
2307
  def run_container(self):
1924
2308
  '''
1925
2309
  Runs a container
@@ -2051,7 +2435,54 @@ examples:
2051
2435
  type = str,
2052
2436
  help= f'Select project: {project}')
2053
2437
  return parser
2054
-
2438
+
2439
+ def _print_compute_tasks(self, schema, analysis = None, limit = 10):
2440
+ '''Print the ComputeTask table by status, with the last line of each task log.
2441
+
2442
+ Shows the most recent ``limit`` tasks of each status and the count of the rest.
2443
+ '''
2444
+ tasks = schema.ComputeTask()
2445
+ if not analysis is None:
2446
+ tasks = tasks & dict(task_name = analysis)
2447
+ title = 'ComputeTask table' + (f' [{analysis}]' if not analysis is None else '')
2448
+ print(tcolor['m'](f"++++++++++++++++++++ {title} ++++++++++++++++++++"))
2449
+ if not len(tasks):
2450
+ print('\t no compute tasks.')
2451
+ return
2452
+ statuses = [('WORKING', tcolor['c']), ('WAITING', tcolor['y']), ('ON SERVER', tcolor['b']),
2453
+ ('FAILED', tcolor['r']), ('CANCELED', tcolor['o']), ('COMPLETED', tcolor['g'])]
2454
+ def _brief(log, width = 100):
2455
+ if log is None:
2456
+ return ''
2457
+ if isinstance(log, bytes):
2458
+ log = log.decode('utf-8', errors = 'replace')
2459
+ lines = [l.strip() for l in str(log).splitlines() if len(l.strip())]
2460
+ line = ' '.join(lines[-1].split()) if len(lines) else ''
2461
+ return line if len(line) <= width else line[:width-3] + '...'
2462
+ def _time(t):
2463
+ return t.strftime('%Y-%m-%d %H:%M') if not t is None else ''
2464
+ for status, cc in statuses:
2465
+ sel = tasks & f'task_status LIKE "%{status}%"'
2466
+ n = len(sel)
2467
+ if not n:
2468
+ continue
2469
+ print(cc(f"{status} ({n})"))
2470
+ rows = sel.fetch('job_id', 'task_name', 'task_target', 'task_host', 'subject_name',
2471
+ 'session_name', 'task_starttime', 'task_endtime', 'task_log',
2472
+ as_dict = True, order_by = 'job_id DESC', limit = limit)
2473
+ for r in rows:
2474
+ where = '/'.join([str(r[k]) for k in ('subject_name', 'session_name') if not r[k] is None])
2475
+ when = _time(r['task_endtime'] if status in ('COMPLETED', 'FAILED', 'CANCELED') and
2476
+ not r['task_endtime'] is None else r['task_starttime'])
2477
+ print(f"\t {r['job_id']} {r['task_name']} {where} "
2478
+ f"target: {r['task_target']} host: {r['task_host']}" +
2479
+ (f" {tcolor['y'](when)}" if len(when) else ''))
2480
+ log = _brief(r['task_log'])
2481
+ if len(log):
2482
+ print(f"\t\t{log}")
2483
+ if n > limit:
2484
+ print(f"\t ... and {n - limit} more")
2485
+
2055
2486
  def _get_default_arg(self,argument,cli_arg = 'submit', default = None):
2056
2487
  # checks if there is a default in the options
2057
2488
  if not f'{cli_arg}_defaults' in labdata_preferences.keys():
@@ -5,6 +5,7 @@ Tasks can be scheduled and run on dedicated containers using job schedulers.
5
5
 
6
6
  Available compute tasks:
7
7
  - SpksCompute: Spike sorting using Kilosort/Phy via SPKS
8
+ - SpikeInterfaceCompute: Spike sorting with the sorters of SpikeInterface
8
9
  - DeeplabcutCompute: Animal pose estimation using DeepLabCut
9
10
  - LightningPoseCompute: Animal pose estimation using Lightning Pose
10
11
 
@@ -32,6 +33,7 @@ The compute tasks handle:
32
33
  from .utils import *
33
34
  from .singularity import build_singularity_container
34
35
  from .ephys import SpksCompute
36
+ from .spikeinterface import SpikeInterfaceCompute
35
37
  from .pose import DeeplabcutCompute, LightningPoseCompute
36
38
  from .caiman import CaimanCompute
37
39
  from .suite2p import Suite2pCompute