p2api 1.0.6__py2.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.
p2api/p2api.py ADDED
@@ -0,0 +1,1808 @@
1
+ """
2
+ This file provides an example binding to ESO's RESTful phase 2 programmatic interface.
3
+ The data required and returned by many of the API calls is instrument-specific.
4
+ In order to fully understand the data and behaviour of the API please consult
5
+ the documentation at https://www.eso.org/copdemo/apidoc/
6
+ The api URL specified with each API should help to locate the corresponding
7
+ API documentation at the above URL.
8
+
9
+ Unless otherwise mentioned, each API call consistently returns a tuple (data, version):
10
+ * data - returned data in JSON format
11
+ * version - version of the data, required for future modifications (HTTP ETags are used for this versioning)
12
+
13
+ ___________________________________________________________________________
14
+ MIT License
15
+ ___________________________________________________________________________
16
+ Copyright (c) 2021 Thomas Bierwirth, European Southern Observatory
17
+
18
+ Permission is hereby granted, free of charge, to any person obtaining a copy
19
+ of this software and associated documentation files (the "Software"), to deal
20
+ in the Software without restriction, including without limitation the rights
21
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
22
+ copies of the Software, and to permit persons to whom the Software is
23
+ furnished to do so, subject to the following conditions:
24
+
25
+ The above copyright notice and this permission notice shall be included in all
26
+ copies or substantial portions of the Software.
27
+
28
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
29
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
30
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
31
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
32
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
33
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
34
+ SOFTWARE.
35
+ """
36
+ from __future__ import print_function
37
+ import os
38
+ import io
39
+ import json
40
+ import requests
41
+ import re
42
+
43
+ ## The hatchling way...
44
+ try:
45
+ from p2api.__about__ import __version__
46
+ except:
47
+ # Need this construct to run in VSCode
48
+ from __about__ import __version__
49
+ P2_API_VERSION = __version__
50
+
51
+ # ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
52
+ API_URL = {
53
+ 'production' : 'https://www.eso.org/cop/api/v1',
54
+ 'production_lasilla': 'https://www.eso.org/copls/api/v1',
55
+ 'demo' : 'https://www.eso.org/copdemo/api/v1',
56
+ }
57
+ LOGIN_URL = {
58
+ 'production' : 'https://www.eso.org/cop/api/login',
59
+ 'production_lasilla': 'https://www.eso.org/copls/api/login',
60
+ 'demo' : 'https://www.eso.org/copdemo/api/login',
61
+ }
62
+ P2FC_URL = {
63
+ 'production' : 'https://www.eso.org/p2fc/',
64
+ 'production_lasilla': 'https://www.eso.org/p2fc/',
65
+ 'demo' : 'https://www.eso.org/p2fcdemo/',
66
+ }
67
+
68
+ class P2Error(Exception):
69
+ pass
70
+
71
+ class ApiConnection(object):
72
+ # ---------- PUBLIC API ----------
73
+ def __init__(self, *args, **kwargs) :
74
+ #def __init__(self, environment, username, password, debug=False):
75
+ """
76
+ Set environment, log in to the API and obtain access token for further
77
+ calls. Returns authenticated api connection required to invoke other
78
+ API calls.
79
+
80
+ Supported environments are
81
+ - 'production' : for Paranal Observation Preparation
82
+ - 'production_lasilla' : for La Silla Observation Preparation
83
+ - 'demo' : to experiment with both Paranal and La Silla
84
+
85
+ usage:
86
+ ::
87
+
88
+ api = p2api.ApiConnection('demo', '52052', 'tutorial')
89
+ api = p2api.ApiConnection('demo', '52052', 'tutorial', True)
90
+ api = p2api.ApiConnection(environment='demo', username='52052', password='tutorial', debug=True)
91
+ or
92
+ api = p2api.ApiConnection(url='https://www.eso.org/copdemo/api/v1', access_token='<access_token>')
93
+ api = p2api.ApiConnection(url='https://www.eso.org/copdemo/api/v1/obsBlocks/{obId}', access_token='<access_token>')
94
+ api = p2api.ApiConnection(url='https://www.eso.org/copdemo/api/v1/containers/{containerId}', access_token='<access_token>')
95
+
96
+
97
+ """
98
+ if 'environment' in kwargs or len(args) > 0 :
99
+ # Define parameters...
100
+ param_values={}
101
+ required_params=[
102
+ 'environment',
103
+ 'username',
104
+ 'password',
105
+ ]
106
+ optional_params=[
107
+ 'debug',
108
+ ]
109
+ optional_param_defaults=[
110
+ False,
111
+ ]
112
+
113
+ # Set parameter values from provided arguments...
114
+ for i,k in enumerate(args) :
115
+ param_values[(required_params+optional_params)[i]]=args[i]
116
+
117
+ # Check all required parameters have been specified
118
+ required_params_not_specified=[]
119
+ for k in required_params :
120
+ if k not in param_values.keys() :
121
+ if k in kwargs.keys() :
122
+ param_values[k]=kwargs[k]
123
+ else :
124
+ required_params_not_specified+=['required parameter %s not specified' %(k),]
125
+ if required_params_not_specified != [] :
126
+ raise P2Error(
127
+ 500,
128
+ 'POST',
129
+ ', '.join(required_params_not_specified)
130
+ ,
131
+ )
132
+ # Set default values for optional parameters not specified
133
+ for i,k in enumerate(optional_params) :
134
+ if k not in param_values.keys() :
135
+ param_values[k]=optional_param_defaults[i]
136
+ # Parameters now set...
137
+
138
+ self.debug=param_values['debug']
139
+ self.request_count = 0
140
+ if param_values['environment'] not in API_URL.keys() :
141
+ raise P2Error(500, 'POST', param_values['environment'], 'environment not supported')
142
+ self.apiUrl = API_URL[param_values['environment']]
143
+ self.loginUrl = LOGIN_URL[param_values['environment']]
144
+ self.session = self.requests_retry_session()
145
+
146
+ r = requests.post(
147
+ self.loginUrl,
148
+ data={
149
+ 'username': param_values['username'],
150
+ 'password': param_values['password'],
151
+ }
152
+ )
153
+ if r.status_code == requests.codes.ok:
154
+ body = r.json()
155
+ self.access_token = body['access_token']
156
+ else:
157
+ raise P2Error(r.status_code, 'POST', self.loginUrl, 'cannot login')
158
+
159
+ elif 'url' in kwargs :
160
+ self.debug = kwargs.get('debug') or False
161
+ self.request_count = 0
162
+ url_is_valid=False
163
+ for environment in API_URL.keys() :
164
+ if API_URL[environment] in kwargs['url'] :
165
+ url_is_valid=True
166
+ break
167
+ if not url_is_valid :
168
+ raise P2Error(500, 'POST', kwargs['url'], 'Invalid URL')
169
+ self.apiUrl = API_URL[environment]
170
+ self.loginUrl = LOGIN_URL[environment]
171
+ self.access_token = kwargs['access_token']
172
+ self.session = self.requests_retry_session()
173
+ else :
174
+ raise P2Error(500, 'POST', LOGIN_URL[kwargs.get('environment','production')], 'Unknown API init method')
175
+
176
+ # ---------- RUN-LEVEL APIs ----------
177
+ """
178
+ Still pending implementation:
179
+ New end points for various requests:
180
+ /obsRuns/{runId}/request/instrumentChange
181
+ /obsRuns/{runId}/request/targetChange
182
+ /obsRuns/{runId}/request/waiver
183
+ """
184
+ def getRuns(self):
185
+ """Retrieve observing runs owned by or delegated to logged-in user.
186
+
187
+ api: GET /obsRuns
188
+
189
+ usage:
190
+ ::
191
+
192
+ runs, _ = api.getRuns()
193
+ """
194
+ return self.get('/obsRuns')
195
+
196
+ def getRun(self, runId):
197
+ """Get a single observing run.
198
+
199
+ api: GET /obsRuns/{runId}
200
+
201
+ usage:
202
+ ::
203
+
204
+ run, _ = api.getRun(runId)
205
+ """
206
+ return self.get('/obsRuns/%d' % runId)
207
+
208
+ def getPhase1Targets(self, runId):
209
+ """Retrieve the list of targets defined in phase 1.
210
+
211
+ api: GET /obsRuns/{runId}/phase1/targets
212
+
213
+ usage:
214
+ ::
215
+
216
+ targets, _ = api.getPhase1Targets(runId)
217
+ """
218
+ return self.get('/obsRuns/%d/phase1/targets' % runId)
219
+
220
+ def getBlockedTimes(self, runId):
221
+ """Retrieve a list of blocked times on the telescope in the
222
+ scheduled period of this observing run.
223
+
224
+ api: GET /obsRuns/{runId}/phase1/blockedTimes
225
+
226
+ usage:
227
+ ::
228
+
229
+ targets, _ = api.getBlockedTimes(runId)
230
+ """
231
+ return self.get('/obsRuns/%d/phase1/blockedTimes' % runId)
232
+
233
+ def submitRun(self, runId):
234
+ """Submit the observing run to ESO for review.
235
+
236
+ api : POST /obsRuns/{runId}/submit
237
+
238
+ usage:
239
+ ::
240
+
241
+ api.submitRun(runId)
242
+ """
243
+ return self.post('/obsRuns/%d/submit' % runId)
244
+
245
+ def getReadmeSchema(self, runId):
246
+ """Retrieve the schema definition for the ReadMe file of this observing run.
247
+
248
+ api: GET /obsRuns/{runId}/readme/schema
249
+
250
+ usage:
251
+ ::
252
+
253
+ readmeSchema, _ = api.getReadmeSchema(runId)
254
+ """
255
+ return self.get('/obsRuns/%d/readme/schema' % runId)
256
+
257
+ def getReadme(self, runId):
258
+ """Retrieve the ReadMe file of this observing run.
259
+
260
+ api: GET /obsRuns/{runId}/readme
261
+
262
+ usage:
263
+ ::
264
+
265
+ readme, readmeVersion = api.getReadme(runId)
266
+ """
267
+ return self.get('/obsRuns/%d/readme' % runId)
268
+
269
+ def saveReadme(self, runId, readme, version):
270
+ """
271
+ Update the ReadMe file.
272
+
273
+ api: PUT /obsRuns/{runId}/readme.
274
+
275
+ usage:
276
+ ::
277
+
278
+ readme, readmeVersion = api.saveReadme(runId, readme, version)
279
+ """
280
+ return self.put('/obsRuns/%d/readme' % runId, readme, version)
281
+
282
+ # ---------- CONTAINER-LEVEL APIs ----------
283
+ def getContainer(self, containerId):
284
+ """Get container information.
285
+
286
+ api: GET /containers/{containerId}
287
+
288
+ usage:
289
+ ::
290
+
291
+ container, containerVersion = api.getContainer(containerId)
292
+ """
293
+ return self.get('/containers/%d' % containerId)
294
+
295
+ def saveContainer(self, container, version):
296
+ """Update container information.
297
+
298
+ api: PUT /containers/{containerId}
299
+
300
+ usage:
301
+ ::
302
+
303
+ container, containerVersion = api.getContainer(containerId)
304
+ # modify some container properties ...
305
+ container['userPriority'] = 10
306
+ # save changes
307
+ container, containerVersion = api.saveContainer(container, containerVersion)
308
+ """
309
+ return self.put('/containers/%d' % container['containerId'], container, version)
310
+
311
+ def deleteContainer(self, containerId, version):
312
+ """Delete container. The container must be empty.
313
+
314
+ api: DELETE /containers/{containerId}
315
+
316
+ usage:
317
+ ::
318
+
319
+ container, containerVersion = api.getContainer(container)
320
+ api.deleteContainer(container['containerId'], containerVersion)
321
+ """
322
+ return self.delete('/containers/%d' % containerId, etag=version)
323
+
324
+ def createItem(self, itemType, containerId, name):
325
+ """Create a new OB, CB, Folder, Group, Concatenation or TimeLink
326
+ at the end of the given container.
327
+
328
+ The initial values for the observing constraints of a new OB are those
329
+ specified during phase 1, or the instrument defaults.
330
+
331
+ Visitor Mode: Groups, Concatenations and TimeLinks cannot be created.
332
+
333
+ Service Mode: Groups, Concatenations and TimeLinks cannot contain other
334
+ containers, only OBs and CBs.
335
+
336
+ See also wrappers below to create specific items by name.
337
+
338
+ api: POST /containers/{containerId}/items
339
+
340
+ usage:
341
+ ::
342
+
343
+ ob, obVersion = api.createItem('OB', containerId, 'OliOB')
344
+ """
345
+ return self.post('/containers/%d/items' % containerId, {'itemType': itemType, 'name': name})
346
+
347
+ def createOB(self, containerId, name):
348
+ """Create a new Observing Block.
349
+
350
+ api: POST /containers/{containerId}/items
351
+
352
+ usage:
353
+ ::
354
+
355
+ ob, obVersion = api.createOB(containerId, 'OliOB')
356
+ """
357
+ return self.createItem('OB', containerId, name)
358
+
359
+ def createCB(self, containerId, name):
360
+ """Create a new Calibration Block.
361
+
362
+ api : POST /containers/{containerId}/items
363
+
364
+ usage:
365
+ ::
366
+
367
+ cb, cbVersion = api.createCB(containerId, 'OliCB')
368
+ """
369
+ return self.createItem('CB', containerId, name)
370
+
371
+ def createFolder(self, containerId, name):
372
+ """Create a new Folder.
373
+
374
+ api: POST /containers/{containerId}/items
375
+
376
+ usage:
377
+ ::
378
+
379
+ fld, fldVersion = api.createFolder(containerId, 'OliFolder')
380
+ """
381
+ return self.createItem('Folder', containerId, name)
382
+
383
+ def createGroup(self, containerId, name):
384
+ """Create a new Group.
385
+
386
+ api: POST /containers/{containerId}/items
387
+
388
+ usage:
389
+ ::
390
+
391
+ grp, grpVersion = api.createGroup(containerId, 'OliGroup')
392
+ """
393
+ return self.createItem('Group', containerId, name)
394
+
395
+ def createConcatenation(self, containerId, name):
396
+ """Create a new Concatenation.
397
+
398
+ api: POST /containers/{containerId}/items
399
+
400
+ usage:
401
+ ::
402
+
403
+ con, conVersion = api.createConcatenation(containerId, 'OliConcatenation')
404
+ """
405
+ return self.createItem('Concatenation', containerId, name)
406
+
407
+ def createTimeLink(self, containerId, name):
408
+ """Create a new TimeLink
409
+
410
+ api: POST /containers/{containerId}/items
411
+
412
+ usage:
413
+ ::
414
+
415
+ tl, tlVersion = api.createTimeLink(containerId, 'OliTimeLink')
416
+ """
417
+ return self.createItem('TimeLink', containerId, name)
418
+
419
+ def getItems(self, containerId):
420
+ """ Get ordered list of OBs and containers inside a container.
421
+
422
+ api: GET /containers/{containerId}/items
423
+
424
+ usage:
425
+ ::
426
+
427
+ items, itemsVersion = api.getItems(containerId)
428
+ """
429
+ return self.get('/containers/%d/items' % containerId)
430
+
431
+ def reorderItems(self, containerId, items, version):
432
+ """Change order of OBs and containers inside a container.
433
+
434
+ api: PUT /containers/{containerId}/items
435
+
436
+ usage:
437
+ ::
438
+
439
+ items, itemsVersion = api.getItems(containerId)
440
+ # modify items order ...
441
+ # save changes
442
+ items, itemsVersion = api.reorderItems(containerId, items, itemsVersion)
443
+ """
444
+ return self.put('/containers/%d/items' % containerId, items, version)
445
+
446
+ def moveFolder(self, folderId, containerId):
447
+ """Move existing folder into destination container.
448
+
449
+ api: POST /containers/{containerId}/items/append
450
+
451
+ usage:
452
+ ::
453
+
454
+ api.moveFolder(folderId, containerId)
455
+ """
456
+ return self.post('/containers/%d/items/append' % containerId,
457
+ {'itemType': 'Folder', 'containerId': folderId})
458
+
459
+ def moveGroup(self, groupId, containerId):
460
+ """Move existing group into destination container.
461
+
462
+ api: POST /containers/{containerId}/items/append
463
+
464
+ usage:
465
+ ::
466
+
467
+ api.moveGroup(groupId, containerId)
468
+ """
469
+ return self.post('/containers/%d/items/append' % containerId,
470
+ {'itemType': 'Group', 'containerId': groupId})
471
+
472
+ def moveConcatenation(self, concatenationId, containerId):
473
+ """Move existing concatenation into destination container.
474
+
475
+ api: POST /containers/{containerId}/items/append
476
+
477
+ usage:
478
+ ::
479
+
480
+ api.moveConcatenation(concatenationId, containerId)
481
+ """
482
+ return self.post('/containers/%d/items/append' % containerId,
483
+ {'itemType': 'Concatenation', 'containerId': concatenationId})
484
+
485
+ def moveTimeLink(self, timeLinkId, containerId):
486
+ """Move existing timelink into destination container.
487
+
488
+ api: POST /containers/{containerId}/items/append
489
+
490
+ usage:
491
+ ::
492
+
493
+ api.moveTimeLink(folderId, containerId)
494
+ """
495
+ return self.post('/containers/%d/items/append' % containerId,
496
+ {'itemType': 'TimeLink', 'containerId': timeLinkId})
497
+
498
+ def moveOB(self, obId, containerId):
499
+ """Move existing OB into destination container.
500
+
501
+ api: POST /containers/{containerId}/items/append
502
+
503
+ usage:
504
+ ::
505
+
506
+ api.moveOB(obId, containerId)
507
+ """
508
+ return self.post('/containers/%d/items/append' % containerId,
509
+ {'itemType': 'OB', 'obId': obId})
510
+
511
+ def moveCB(self, obId, containerId):
512
+ """Move existing CB into destination container.
513
+
514
+ api: POST /containers/{containerId}/items/append
515
+
516
+ usage:
517
+ ::
518
+
519
+ api.moveCB(obId, containerId)
520
+ """
521
+ return self.post('/containers/%d/items/append' % containerId,
522
+ {'itemType': 'CB', 'obId': obId})
523
+
524
+ def getOBSummary(self, containerId):
525
+ """Get more detailed summary of all OBs in a container
526
+
527
+ api: GET /containers/{containerId}/items/summary
528
+
529
+ usage:
530
+ ::
531
+
532
+ api.getOBSummary(containerId)
533
+ """
534
+ return self.get('/containers/%d/items/summary' % containerId)
535
+
536
+ def verifyContainer(self, containerId, submit):
537
+ """Verify a Group, Concatenation or TimeLink.
538
+
539
+ If submit is false, a Group, Concatenation, or TimeLink can be verified
540
+ in any container status. If submit is true, the container must have
541
+ container status "-" or "P".
542
+
543
+ OBs inside the container must be verified first, i.e. they have a status
544
+ of "D" or later.
545
+
546
+ The instrument-specific verification scripts for a container are executed.
547
+ If submit is true and verification is successful, the container status
548
+ is changed to "D".
549
+
550
+ A change of the container status will invalidate the ETag of the container.
551
+
552
+ api: POST /containers/{containerId}/verify
553
+
554
+ usage:
555
+ ::
556
+
557
+ response, _ = api.verifyContainer(containerId, True)
558
+ if response['observable']:
559
+ print('Your container is observable!')
560
+ else:
561
+ print('Your container is >>not observable<<. See messages below.')
562
+ print(' ')
563
+ print(' '.join(response['messages']))
564
+ """
565
+ return self.post('/containers/%d/verify' % containerId, {'submit': submit})
566
+
567
+ def reviseContainer(self, containerId):
568
+ """Open Group/Concatenation/TimeLink for further changes.
569
+
570
+ Change the container status back to "P".
571
+ This is the counterpart to verification with submission.
572
+
573
+ api: POST /containers/{containerId}/revise
574
+
575
+ usage:
576
+ ::
577
+
578
+ api.reviseContainer(containerId)
579
+ """
580
+ return self.post('/containers/%d/revise' % containerId)
581
+
582
+ def exportContainer(self, containerId, filename):
583
+ """Export this container into a ZIP file.
584
+
585
+ api: GET /containers/{containerId}/export
586
+
587
+ usage:
588
+ ::
589
+
590
+ api.exportContainer(containerId, 'myContainer.zip')
591
+ """
592
+ self.downloadBinaryFile('/containers/%d/export' % containerId, filename)
593
+
594
+ # ---------- OB-LEVEL APIs ----------
595
+ def getOB(self, obId):
596
+ """Get existing OB or CB.
597
+
598
+ api: GET /obsBlocks/{obId}
599
+
600
+ usage:
601
+ ::
602
+
603
+ ob, obVersion = api.getOB(obId)
604
+ """
605
+ return self.get('/obsBlocks/%d' % obId)
606
+
607
+ def saveOB(self, ob, version):
608
+ """Update existing OB or CB.
609
+
610
+ The format of an OB is instrument-specific and differs for OBs and CBs.
611
+ The instrument defines the applicable observingConstraints.
612
+ A CB does not have a target. The properties itemType, obId, obStatus,
613
+ ipVersion, exposureTime and executionTime cannot be changed.
614
+ The OB must have the latest ipVersion. Refer to the API spec
615
+ for further updating constraints.
616
+
617
+ api: PUT /obsBlocks/{obId}
618
+
619
+ usage:
620
+ ::
621
+
622
+ ob, obVersion = api.getOB(obId)
623
+ # modify some OB properties ...
624
+ ob['userPriority'] = 42
625
+ ob['target']['name'] = 'M 32 -- Interacting Galaxies'
626
+ ob['target']['ra'] = '00:42:41.825'
627
+ ob['target']['dec'] = '-60:51:54.610'
628
+ ob['constraints']['name'] = 'My hardest constraints ever'
629
+ ob['constraints']['airmass'] = 1.3
630
+ # save changes
631
+ ob, obVersion = api.saveOB(ob, obVersion)
632
+ """
633
+ return self.put('/obsBlocks/%d' % ob['obId'], ob, version)
634
+
635
+ def deleteOB(self, obId, version):
636
+ """Delete existing OB or CB.
637
+
638
+ Visitor Mode: The OB must have never been executed.
639
+
640
+ Service Mode: The OB must have status (-), (P) or (D) and must have never been executed.
641
+
642
+ api: DELETE /obsBlocks/{obId}
643
+
644
+ usage:
645
+ ::
646
+
647
+ ob, obVersion = api.getOB(obId)
648
+ api.deleteOB(ob, obVersion)
649
+ """
650
+ return self.delete('/obsBlocks/%d' % obId, etag=version)
651
+
652
+ def verifyOB(self, obId, submit):
653
+ """Verify OB and submit it for observation.
654
+
655
+ If submit is false, an OB can be verified in any status. If submit is
656
+ true, a loose service mode OB must be in status "P" or "-" and a visitor
657
+ mode OB must be in a status other than "S", "A", "X", or "+".
658
+
659
+ api: POST /obsBlocks/{obId}/verify
660
+
661
+ usage:
662
+ ::
663
+
664
+ response, _ = api.verifyOB(obId, True)
665
+ if response['observable']:
666
+ print('Your OB' , obId, ob['name'], 'is observable!')
667
+ else:
668
+ print('OB', obId, 'is >>not observable<<. See messages below.')
669
+ print(' ')
670
+ print(' '.join(response['messages']))
671
+ """
672
+ return self.post('/obsBlocks/%d/verify' % obId, {'submit': submit})
673
+
674
+ def reviseOB(self, obId):
675
+ """Back to the drawing board.
676
+
677
+ Change the OB status back to "P", i.e. mark it as not observable.
678
+ This is the counterpart to verification with submission.
679
+
680
+ A visitor mode OB can be in any status. The status is changed to "P".
681
+ The OB will be removed from any visitor execution sequences because
682
+ it is no longer in an observable status; this changes the ETag of
683
+ the affected visitor execution sequences.
684
+
685
+ A service mode OB can only be revised in status "D". The status is
686
+ changed to "P". If the OB is a member of a Group, Concatenation or
687
+ TimeLink with container status other than "-" or "P", the container
688
+ status will be changed to "P".
689
+
690
+ This call invalidates the ETag of the OB, and possibly of the parent
691
+ container, if the container status was changed.
692
+
693
+ api: POST /obsBlocks/{obId}/revise
694
+
695
+ usage:
696
+ ::
697
+
698
+ pi.reviseOB(obId)
699
+ """
700
+ return self.post('/obsBlocks/%d/revise' % obId)
701
+
702
+ def duplicateOB(self, obId, containerId=0):
703
+ """Duplicate OB.
704
+
705
+ Create a new OB or CB identical to this one in the same or a different
706
+ container. The new OB is appended at the end of the container. The
707
+ destination may be in a different observing run of the same instrument.
708
+
709
+ api: POST /obsBlocks/{obId}/duplicate
710
+
711
+ usage:
712
+ ::
713
+
714
+ duplicatedOB, duplicatedOBVersion = api.duplicateOB(obId)
715
+ duplicatedOB, duplicatedOBVersion = api.duplicateOB(obId, containerId)
716
+ """
717
+ data = {}
718
+ if containerId > 0:
719
+ data = {'containerId': containerId}
720
+ return self.post('/obsBlocks/%d/duplicate' % obId, data)
721
+
722
+ def migrateOB(self, obId, dryRun):
723
+ """Migrate OB to latest instrument package.
724
+
725
+ api: POST /obsBlocks/{obId}/migrate
726
+
727
+ usage:
728
+ ::
729
+
730
+ result, _ = api.migrateOB(obId, false)
731
+ result, _ = api.migrateOB(obId, true)
732
+ """
733
+ return self.post('/obsBlocks/%d/migrate' % obId, {'dryRun': dryRun})
734
+
735
+ def executionTime(self, Id, itemType=None):
736
+ """Compute the execution time for the OB, or all OBs in or below a container.
737
+
738
+ api: POST /obsBlocks/{Id}/executionTime
739
+
740
+ usage:
741
+ ::
742
+
743
+ obInfo = api.executionTime(Id)
744
+ containersInfo = api.executionTime(Id)
745
+ containersInfo = api.executionTime(Id, itemType='Folder')
746
+ containersInfo = api.executionTime(Id, itemType='Group')
747
+
748
+ If Id is a an obId, a obInfo dictionary will be returned.
749
+ If Id is a containerId (Folder, Group, Concatenation, TimeLink) a list
750
+ of obInfo and/or containerInfo dictionaries will be returned. A containerInfo
751
+ dictionary provides info about the container, and the OBs contained in that
752
+ container. Folders do not generate containerInfo dictionaries.
753
+ """
754
+ if itemType in [None, 'OB'] :
755
+ try :
756
+ return self.post('/obsBlocks/%d/executionTime' % Id)[0]
757
+ except :
758
+ # We assume it failed because Id is not an OB,
759
+ # thus it must be a container, so we work through the container
760
+ pass
761
+ obsInfo=[]
762
+ c,_ = self.getContainer(Id)
763
+ if c['itemType'] in ['Group','Concatenation','TimeLink',] :
764
+ c['exposureTime']=0
765
+ c['executionTime']=0
766
+ c['obs']=self.post('/containers/%d/items/executionTimes' % Id)[0]
767
+ for ob in c['obs'] :
768
+ c['exposureTime']+=ob['exposureTime']
769
+ c['executionTime']+=ob['executionTime']
770
+ obsInfo+=[c,]
771
+ else:
772
+ # Id must be a folder...
773
+ items, _ = self.getItems(Id)
774
+ for item in items :
775
+ obsInfo+=[self.executionTime(item.get('obId',item.get('containerId')), itemType=item['itemType'])]
776
+ return obsInfo
777
+
778
+ def exportOB(self, obId, filename):
779
+ """Export this OB into a ZIP file.
780
+
781
+ api: GET /obsBlocks/{obId}/export
782
+
783
+ usage:
784
+ ::
785
+
786
+ api.exportOB(obId, 'myOB.zip')
787
+ """
788
+ self.downloadBinaryFile('/obsBlocks/%d/export' % obId, filename)
789
+
790
+ def getOBExecutions(self, obId, night):
791
+ """Get executions during a given night.
792
+
793
+ api: GET /obsBlocks/{obId}/executions
794
+
795
+ usage:
796
+ ::
797
+
798
+ obExecutions, _ = getOBExecutions(obId, '2018-01-01')
799
+ """
800
+ return self.get('/obsBlocks/%d/executions?night=%s' % (obId, night))
801
+
802
+ def getNightExecutions(self, instrument, night ):
803
+ """Get executions during a given night for a specific instrument
804
+
805
+ api: GET /executions/{instrument}
806
+
807
+ usage:
808
+ ::
809
+
810
+ executions, _ = api.getNightExecutions(instrument, night)
811
+ executions, _ = api.getNightExecutions(instrument, '2016-01-19')
812
+ """
813
+ return self.get('/executions/%s?night=%s' %(instrument,night))
814
+
815
+ def getOBCompletelyFilledIn(self, obId):
816
+ """Check that the OB's templates are completely filled in.
817
+
818
+ Performs a quick check that there are neither missing templates
819
+ nor missing parameters on this OB.
820
+
821
+ This is not a full validation.
822
+
823
+ api: GET /obsBlocks/{obId}/templates/completelyFilledIn
824
+
825
+ usage:
826
+ ::
827
+
828
+ response, _ = api.getOBCompletelyFilledIn(obId)
829
+ print('OB complete:', response['complete'])
830
+ """
831
+ return self.get('/obsBlocks/%d/templates/completelyFilledIn' % obId)
832
+
833
+ def getAbsoluteTimeConstraints(self, obId):
834
+ """Get list of absolute time constraints.
835
+
836
+ The list is sorted in ascending order by from.
837
+ Date and time are in ISO-8601 format for the UTC timezone.
838
+ A CB does not have absolute time constraints and returns an empty list.
839
+
840
+ api: GET /obsBlocks/{obId}/timeConstraints/absolute
841
+
842
+ usage:
843
+ ::
844
+
845
+ absTCs, atcVersion = api.getAbsoluteTimeConstraints(obId)
846
+ """
847
+ return self.get('/obsBlocks/%d/timeConstraints/absolute' % obId)
848
+
849
+ def saveAbsoluteTimeConstraints(self, obId, timeConstraints, version):
850
+ """Set list of absolute time constraints.
851
+
852
+ Date and time are in ISO-8601 format and must use UTC timezone.
853
+ Time intervals must not overlap and from must be earlier than to.
854
+
855
+ api: PUT /obsBlocks/{obId}/timeConstraints/absolute
856
+
857
+ usage:
858
+ ::
859
+
860
+ absTCs, atcVersion = api.getAbsoluteTimeConstraints(obId)
861
+ api.saveAbsoluteTimeConstraints(obId,[
862
+ {
863
+ 'from': '2017-09-01T00:00',
864
+ 'to': '2017-09-30T23:59'
865
+ },
866
+ {
867
+ 'from': '2017-11-01T00:00',
868
+ 'to': '2017-11-30T23:59'
869
+ }
870
+ ], atcVersion)
871
+ """
872
+ return self.put('/obsBlocks/%d/timeConstraints/absolute' % obId, timeConstraints, version)
873
+
874
+ def getSiderealTimeConstraints(self, obId):
875
+ """Get list of sidereal time constraints.
876
+
877
+ The list is sorted in ascending order by from.
878
+ A CB does not have sidereal time constraints and returns an empty list.
879
+
880
+ api: GET /obsBlocks/{obId}/timeConstraints/sidereal
881
+
882
+ usage:
883
+ ::
884
+
885
+ sidTCs, stcVersion = api.getSiderealTimeConstraints(obId)
886
+ """
887
+ return self.get('/obsBlocks/%d/timeConstraints/sidereal' % obId)
888
+
889
+ def saveSiderealTimeConstraints(self, obId, timeConstraints, version):
890
+ """Set list of sidereal time constraints.
891
+
892
+ Time intervals must not overlap. A time interval ending at the starting time
893
+ of another is considered an overlap; avoid this by combining the intervals
894
+ into a single one.
895
+
896
+ There can be a single time interval ending at 24:00; it will not overlap
897
+ a time interval starting at 0:00. An interval ending at 24:00 and one
898
+ starting at 00:00 will be combined into a single interval.
899
+
900
+ 24:00 cannot be used as starting time, and 00:00 cannot be used
901
+ as ending time.
902
+
903
+ api: PUT /obsBlocks/{obId}/timeConstraints/sidereal
904
+
905
+ usage:
906
+ ::
907
+
908
+ sidTCs, stcVersion = api.getSiderealTimeConstraints(obId)
909
+ api.saveSiderealTimeConstraints(obId,[
910
+ {
911
+ 'from': '00:00',
912
+ 'to': '01:00'
913
+ },
914
+ {
915
+ 'from': '03:00',
916
+ 'to': '05:00'
917
+ }
918
+ ], stcVersion)
919
+ """
920
+ return self.put('/obsBlocks/%d/timeConstraints/sidereal' % obId, timeConstraints, version)
921
+
922
+ def createTemplate(self, obId, name):
923
+ """Attach a new template.
924
+
925
+ An OB can have only one acquisition template. Attempting to add a
926
+ second one will result in an error.
927
+
928
+ The OB must have the latest ipVersion.
929
+
930
+ Visitor Mode: The OB can be changed in any status.
931
+
932
+ Service Mode: The OB must have status (-) or (P).
933
+
934
+ api: POST /obsBlocks/{obId}/templates
935
+
936
+ usage:
937
+ ::
938
+
939
+ tpl, tplVersion = api.createTemplate(obId, 'UVES_blue_acq_slit')
940
+ """
941
+ return self.post('/obsBlocks/%d/templates' % obId, {'templateName': name})
942
+
943
+ def getTemplates(self, obId):
944
+ """Get all attached templates.
945
+
946
+ api: GET /obsBlocks/{obId}/templates
947
+
948
+ usage:
949
+ ::
950
+
951
+ templates, templatesVersion = api.getTemplates(obId)
952
+ for t in templates:
953
+ print ('template found:', t['templateName'])
954
+ """
955
+ return self.get('/obsBlocks/%d/templates' % obId)
956
+
957
+ def reorderTemplates(self, obId, templates, version):
958
+ """Change order of attached templates.
959
+
960
+ The list must contain the same items as in the previous GET request.
961
+ Only the order can be changed. Templates must be explicitly added
962
+ with a POST request or deleted with a DELETE request. If there is
963
+ an acquisition template, it must be the first in the list.
964
+
965
+ The OB must have the latest ipVersion.
966
+
967
+ Visitor Mode: The OB can be changed in any status.
968
+
969
+ Service Mode: The OB must have status (-) or (P).
970
+
971
+ api: PUT /obsBlocks/{obId}/templates
972
+
973
+ usage:
974
+ ::
975
+
976
+ tpls, tplsVersion = api.getTemplates(obId)
977
+ # change order of tpls ...
978
+ tpls, tplsVersion = api.reorderTemplates(obId, tpls, tplsVersion)
979
+ """
980
+ return self.put('/obsBlocks/%d/templates' % obId, templates, version)
981
+
982
+ def getTemplate(self, obId, templateId):
983
+ """Get existing template.
984
+
985
+ A parameter value may be null if there is no default value and a
986
+ value was never set.
987
+
988
+ For parameters of type 'paramfile' or 'file' a string is returned.
989
+ For a 'paramfile' this is the value of PAF.NAME in the PAF header;
990
+ for a 'file' the first few characters are returned.
991
+ File parameters are set with a separate call.
992
+
993
+ api: GET /obsBlocks/{obId}/templates/{templateId}
994
+
995
+ usage:
996
+ ::
997
+
998
+ tpl, tplVersion = api.getTemplate(obId, templateId)
999
+ """
1000
+ return self.get('/obsBlocks/%d/templates/%d' % (obId, templateId))
1001
+
1002
+ def setTemplateParams(self, obId, template, params, version):
1003
+ """Helper API to make it easier to update template parameters.
1004
+ Uses saveTemplate() under the hood.
1005
+
1006
+
1007
+ usage:
1008
+ ::
1009
+
1010
+ tpl, tplVersion = api.createTemplate(obId, 'UVES_blue_acq_slit')
1011
+ tpl, tplVersion = api.setTemplateParams(obId, tpl, {
1012
+ 'TEL.GS1.ALPHA': '11:22:33.000',
1013
+ 'INS.COLL.NAID': 'COLL_SR+7',
1014
+ 'INS.DPOL.MODE': 'ON'
1015
+ }, tplVersion)
1016
+ """
1017
+ for p in template['parameters']:
1018
+ p['value'] = params.get(p['name'], p['value'])
1019
+ return self.saveTemplate(obId, template, version)
1020
+
1021
+ def saveTemplate(self, obId, template, version):
1022
+ """Update existing template.
1023
+
1024
+ The templateId, templateName and type cannot be changed.
1025
+
1026
+ When GET returns a value of null, it can be changed to a proper value
1027
+ (or left at null). But a proper value cannot be unset to null.
1028
+
1029
+ File parameters must be set with a separate call; the string value
1030
+ returned from GET cannot be changed.
1031
+
1032
+ The OB must have the latest ipVersion.
1033
+
1034
+ Visitor Mode: The OB can be changed in any status.
1035
+
1036
+ Service Mode: The OB must have status (-) or (P).
1037
+
1038
+ api: PUT /obsBlocks/{obId}/templates/{templateId}
1039
+
1040
+ usage:
1041
+ ::
1042
+
1043
+ It is recommended to use setTemplateParams() instead
1044
+ """
1045
+ return self.put('/obsBlocks/%d/templates/%d' % (obId, template['templateId']), template, etag=version)
1046
+
1047
+ def getFileParam(self, obId, templateId, param, filename):
1048
+ """Get value of a template parameter of type "file" or "paramfile".
1049
+
1050
+ api: GET /obsBlocks/{obId}/templates/{templateId}/{fileParameter}
1051
+
1052
+ usage:
1053
+ ::
1054
+
1055
+ _, version = api.getFileParam(obId, templateId, 'SEQ.REF.FILE1', myFile.paf')
1056
+ """
1057
+ return self.downloadTextFile('/obsBlocks/%d/templates/%d/%s' % (obId, templateId, param), filename)
1058
+
1059
+ def saveFileParam(self, obId, templateId, param, filename, version):
1060
+ """Set value of a parameter of type "file" or "paramfile".
1061
+
1062
+ api: PUT /obsBlocks/{obId}/templates/{templateId}/{fileParameter}
1063
+
1064
+ usage:
1065
+ ::
1066
+
1067
+ _, version = api.getFileParam(obId, templateId, 'SEQ.REF.FILE1', myFile.paf')
1068
+ api.saveFileParam(obId, templateId, 'SEQ.REF.FILE1', newFile.paf', version)
1069
+ """
1070
+ return self.uploadFile('PUT', '/obsBlocks/%d/templates/%d/%s' % (obId, templateId, param), filename,
1071
+ 'text/plain', version)
1072
+
1073
+ def deleteTemplate(self, obId, templateId, version):
1074
+ """Delete existing template.
1075
+
1076
+ The OB must have the latest ipVersion.
1077
+
1078
+ Visitor Mode: The OB can be changed in any status.
1079
+
1080
+ Service Mode: The OB must have status (-) or (P).
1081
+
1082
+ api: DELETE /obsBlocks/{obId}/templates/{templateId}
1083
+
1084
+ usage:
1085
+ ::
1086
+
1087
+ _, tplVersion = api.getTemplate(obId, templateId)
1088
+ api.deleteTemplate(obId, templateId, tplVersion)
1089
+ """
1090
+ return self.delete('/obsBlocks/%d/templates/%d' % (obId, templateId), etag=version)
1091
+
1092
+ def duplicateTemplate(self, obId, templateId, destinationObId=0):
1093
+ """Duplicate this template.
1094
+
1095
+ Create a new template identical to this one in this or a different OB.
1096
+ The new template is appended at the end of the target OB's template list.
1097
+
1098
+ The new template will have a new templateId. All other fields, including
1099
+ any file and paramfile parameters, will be the same as in the original template.
1100
+
1101
+ An OB can have only one acquisition template. Attempting to add a second
1102
+ one will result in an error.
1103
+
1104
+ api: POST /obsBlocks/{obId}/templates/{templateId}/duplicate
1105
+
1106
+ usage:
1107
+ ::
1108
+
1109
+ duplicatedTemplate, duplicatedTemplateVersion = api.duplicateTemplate(obId, templateId)
1110
+ duplicatedTemplate, duplicatedTemplateVersion = api.duplicateTemplate(obId, templateId, destinationObId)
1111
+ """
1112
+ data = {}
1113
+ if destinationObId > 0:
1114
+ data = {'obId': destinationObId}
1115
+ return self.post('/obsBlocks/%d/templates/%d/duplicate' % (obId, templateId), data)
1116
+
1117
+ def getEphemerisFile(self, obId, filename):
1118
+ """Get the ephemeris file of this OB and save it into the given filename.
1119
+
1120
+ If the ephemeris file was never saved, or was deleted, an empty file
1121
+ is returned.
1122
+
1123
+ api: GET /obsBlocks/{obId}/ephemeris
1124
+
1125
+ usage:
1126
+ ::
1127
+
1128
+ _, ephVersion = api.getEphemerisFile(obId, 'ephem.txt')
1129
+ """
1130
+ url = '/obsBlocks/%d/ephemeris' % obId
1131
+ return self.downloadTextFile(url, filename)
1132
+
1133
+ def saveEphemerisFile(self, obId, filename, version):
1134
+ """Save the ephemeris file of this OB.
1135
+
1136
+ A CB cannot have an ephemeris file.
1137
+
1138
+ Service Mode: The OB must have status (-) or (P).
1139
+
1140
+ api: PUT /obsBlocks/{obId}/ephemeris
1141
+
1142
+ usage:
1143
+ ::
1144
+
1145
+ _, ephVersion = api.getEphemerisFile(obId, 'delete_me.txt')
1146
+ _, ephVersion = api.saveEphemerisFile(obId, 'ephem.txt', ephVersion)
1147
+ """
1148
+ return self.uploadFile('PUT', '/obsBlocks/%d/ephemeris' % obId, filename,
1149
+ 'text/plain', version)
1150
+
1151
+ def deleteEphemerisFile(self, obId, version):
1152
+ """Delete the ephemeris file of this OB.
1153
+
1154
+ Service Mode: The OB must have status (-) or (P).
1155
+
1156
+ api: DELETE /obsBlocks/{obId}/ephemeris
1157
+
1158
+ usage:
1159
+ ::
1160
+
1161
+ _, ephVersion = api.getEphemerisFile(obId, 'delete_me.txt')
1162
+ api.deleteEphemerisFile(obId, ephVersion)
1163
+ """
1164
+ return self.request('DELETE', '/obsBlocks/%d/ephemeris' % obId, etag=version)
1165
+
1166
+ def getFindingChartNames(self, obId):
1167
+ """List attached finding charts.
1168
+
1169
+ Returns the list of names of the attached finding charts in the order
1170
+ of their index. If no finding charts are attached, an empty list will
1171
+ be returned.
1172
+
1173
+ api: GET /obsBlocks/{obId}/findingCharts
1174
+
1175
+ usage:
1176
+ ::
1177
+
1178
+ fcNames, _ = api.getFindingChartNames(obId)
1179
+ """
1180
+ return self.get('/obsBlocks/%d/findingCharts' % obId)
1181
+
1182
+ def addFindingChart(self, obId, filename):
1183
+ """Attach a new finding chart in format image/jpeg.
1184
+
1185
+ Up to 5 finding charts can be attached to a single OB.
1186
+ The maximum file size is 1MB (2^20 bytes).
1187
+
1188
+ The OB must have the latest ipVersion.
1189
+
1190
+ Service Mode: The OB must have status (-) or (P).
1191
+
1192
+ api: POST /obsBlocks/{obId}/findingCharts
1193
+
1194
+ usage:
1195
+ ::
1196
+
1197
+ api.addFindingChart(obId, filename)
1198
+ """
1199
+ return self.uploadFile('POST', '/obsBlocks/%d/findingCharts' % obId, filename,
1200
+ 'image/jpeg')
1201
+
1202
+ def generateFindingChart(
1203
+ self,
1204
+ obId,
1205
+ obs_date=None,
1206
+ stretch=None,
1207
+ exponent=None,
1208
+ pmin=None,
1209
+ pmax=None,
1210
+ GAIA_im_IQ=None,
1211
+ GAIA_im_noise=None,
1212
+ survey=None,
1213
+ bkg_image=None,
1214
+ bkg_lam=None,
1215
+ c_stretch=None,
1216
+ c_exponent=None,
1217
+ c_pmin=None,
1218
+ c_pmax=None,
1219
+ c_GAIA_im_IQ=None,
1220
+ c_GAIA_im_noise=None,
1221
+ ):
1222
+ """
1223
+ Generate new finding chart(s) in format image/jpeg using ESO's p2fc service.
1224
+
1225
+ If the JPG creation is successful, then, and only then will ANY and ALL
1226
+ existing p2fc generated FCs will be removed, the new set of 2 (or 3 if a
1227
+ non-default survey or bkg_image is provided) will then be attached. If
1228
+ for whatever reason the FC JPG creation process fails, any FC JPGs
1229
+ already attached to the OB are left untouched.
1230
+
1231
+ The OB must have the latest ipVersion.
1232
+
1233
+ Service Mode: The OB must have status (-) or (P).
1234
+
1235
+ obs_date: must be interpretable by: , e.g. '2019-10-01 02:03:04 UTC'
1236
+
1237
+ bkg_image: should be a local FITS file
1238
+
1239
+ bkg_lam: discription of the bandpass of the image, only needed if file
1240
+ Primary header does not include a keyword BANDPASS or FILTER or is not
1241
+ a 'standard' ESO or GEMINI image.
1242
+
1243
+ survey: a SkyView supported image survey providing FITS
1244
+
1245
+ pmin, pmax, stretch & exponent and c_pmin, c_pmax, c_stretch &
1246
+ c_exponent are passed directly to the APLPY FITSFigure.show_grayscale
1247
+ method,see
1248
+ https://aplpy.readthedocs.io/en/stable/api/aplpy.FITSFigure.html#aplpy.FITSFigure.show_grayscale
1249
+ for details.
1250
+
1251
+ pmin, pmax, stretch & exponent are applied to BOTH default FCs.
1252
+
1253
+ c_pmin, c_pmax, c_stretch & c_exponent are applied to and and all
1254
+ custom FCs.
1255
+
1256
+ GAIA_im_IQ [arcsec] & GAIA_im_noise affect the GAIA pseudo images used
1257
+ for insets and if survey='Gaia' is used.
1258
+
1259
+ GAIA_im_noise is a dimensionless factor, with the noise computed via the
1260
+ numpy method random.normal(loc,scale) with loc=0.0 and:
1261
+ scale=GAIA_im_noise*<brightest_flux_value_in_pseudo_image> The default
1262
+ value is (as at 2020-01): 1e-6
1263
+ Together with pmin and pmax this parameter gives (some) control over the
1264
+ relative appearance of stars of different brightness, and can thus be helpful
1265
+ to (for example) highlight faint, but not insignificant, field star(s).
1266
+
1267
+ GAIA_im_IQ & GAIA_im_noise are used for any and all Gaia pseudo images
1268
+ associated with the default FCs.
1269
+
1270
+ c_GAIA_im_IQ & c_GAIA_im_noise are the equivalents for any and all Gaia
1271
+ pseudo images associated with the custom FCs.
1272
+
1273
+ returns: data
1274
+ data : None or json structure, possibly with warning messages.
1275
+
1276
+ usage:
1277
+ ::
1278
+ data = api.generateFindingChart(obId)
1279
+ data = api.generateFindingChart(obId, obs_date='2020-01-01 05:30:00 UTC')
1280
+ data = api.generateFindingChart(obId, obs_date='2020-01-01 05:30:00 UTC', pmin=2.5, pmax=99., stretch='arcsinh')
1281
+ data = api.generateFindingChart(obId, bkg_image='myobject.fits')
1282
+ data = api.generateFindingChart(obId, obs_date='2020-01-01 05:30:00 UTC', bkg_image='myobject.fits')
1283
+ data = api.generateFindingChart(obId, survey='2MASS-K')
1284
+ data = api.generateFindingChart(obId, obs_date='2020-01-01 05:30:00 UTC', survey='2MASS-K')
1285
+ data = api.generateFindingChart(obId, obs_date='2020-01-01 05:30:00 UTC', survey='2MASS-K', c_stretch='sqrt', c_pmin=10., c_pmax=90. )
1286
+ """
1287
+
1288
+ data = {
1289
+ 'url': self.apiUrl + '/obsBlocks/%d' % obId,
1290
+ 'access_token': self.access_token,
1291
+ 'obsdate': obs_date,
1292
+ 'exponent': exponent,
1293
+ 'pmin': pmin,
1294
+ 'pmax': pmax,
1295
+ 'GAIA_im_IQ': GAIA_im_IQ,
1296
+ 'GAIA_im_noise': GAIA_im_noise,
1297
+ 'bkg_lam': bkg_lam,
1298
+ 'survey': survey,
1299
+ 'c_stretch': c_stretch,
1300
+ 'c_exponent': c_exponent,
1301
+ 'c_pmin': c_pmin,
1302
+ 'c_pmax': c_pmax,
1303
+ 'c_GAIA_im_IQ': c_GAIA_im_IQ,
1304
+ 'c_GAIA_im_noise': c_GAIA_im_noise,
1305
+ }
1306
+ files=None
1307
+ if bkg_image is not None :
1308
+ if os.path.exists(bkg_image) :
1309
+ files={
1310
+ 'bkg_image_fits_file': (
1311
+ os.path.basename(bkg_image),
1312
+ open(bkg_image, 'rb'),
1313
+ 'image/fits',
1314
+ data,
1315
+ )
1316
+ }
1317
+ data=None
1318
+ else :
1319
+ raise P2Error(404, 'POST', self.p2fc_url, "Could not find bkg_image %s" %(bkg_image))
1320
+ return self.p2fc_request('POST', data=data, files=files)
1321
+
1322
+
1323
+ def bkg_image_get_ob_info(
1324
+ self,
1325
+ obId,
1326
+ ):
1327
+ """
1328
+ Get the current background image settings for the specified OB
1329
+
1330
+ returns: data
1331
+ data : None or an <ob_info> json structure, possibly with warning messages.
1332
+
1333
+ usage:
1334
+ ::
1335
+ bkg_image_info = api.bkg_image_get_ob_info(obId)
1336
+ """
1337
+ ob_url=self.apiUrl + '/obsBlocks/%d' % obId
1338
+ return self.p2fc_request('GET', 'bkg_image?url=%s&access_token=%s&mode=%s' %(ob_url, self.access_token, 'get_ob'))
1339
+
1340
+
1341
+ def bkg_image_get_ob_image_list(
1342
+ self,
1343
+ obId,
1344
+ ):
1345
+ """
1346
+ Get the list of currently available background images compatible with the specified OB
1347
+
1348
+ returns: data
1349
+ data : None or an <ob_image_list> json structure, possibly with warning messages.
1350
+
1351
+ usage:
1352
+ ::
1353
+ bkg_image_list = api.bkg_image_get_ob_image_list(obId)
1354
+ """
1355
+ ob_url=self.apiUrl + '/obsBlocks/%d' % obId
1356
+ return self.p2fc_request('GET','bkg_image?url=%s&access_token=%s&mode=%s' %(ob_url, self.access_token, 'get_ob_image_list'))
1357
+
1358
+
1359
+ def bkg_image_set_ob_info(
1360
+ self,
1361
+ obId,
1362
+ info,
1363
+ ):
1364
+ """
1365
+ Set the current background image settings for the specified OB
1366
+
1367
+ The specified info should be one of:
1368
+ - default
1369
+ - gaia
1370
+ - survey|<survey_name>, e.g. 'survey|2MASS-K'
1371
+ - eso_im|<DPid>, e.g. 'eso_im|ADP.2015-05-11T10:19:53.607
1372
+ - user_bkg_image|<fits_hash>, e.g. 'user_bkg_image|00c98a6dde73f309f058378399cad019f8405603c63d74b5f2ba342a8ae0a8f1'
1373
+
1374
+ returns: data
1375
+ data : None or an <ob_info> json structure, possibly with warning messages.
1376
+
1377
+ usage:
1378
+ ::
1379
+ bkg_image_info = api.bkg_image_set_ob_info(obId, 'default')
1380
+ bkg_image_info = api.bkg_image_set_ob_info(obId, 'gaia')
1381
+ bkg_image_info = api.bkg_image_set_ob_info(obId, 'survey|2MASS-K')
1382
+ bkg_image_info = api.bkg_image_set_ob_info(obId, 'eso_im|ADP.2015-05-11T10:19:53.607')
1383
+ bkg_image_info = api.bkg_image_set_ob_info(obId, 'user_bkg_image|00c98a6dde73f309f058378399cad019f8405603c63d74b5f2ba342a8ae0a8f1')
1384
+
1385
+ bkg_image_list = api.bkg_image_get_ob_image_list(obId)
1386
+ first_image=list(bkg_image_list.keys())[0]
1387
+ fits_hash=bkg_image_list[first_image]['fits_hash']
1388
+ bkg_image_info = api.bkg_image_set_ob_info(obId, 'user_bkg_image|%s' %(fits_hash))
1389
+ """
1390
+ data = {
1391
+ 'url': self.apiUrl + '/obsBlocks/%d' % obId,
1392
+ 'access_token': self.access_token,
1393
+ 'mode': "set_ob_info",
1394
+ 'value': info,
1395
+ }
1396
+ return self.p2fc_request('POST', data=data)
1397
+
1398
+
1399
+ def bkg_image_upload(
1400
+ self,
1401
+ obId,
1402
+ bkg_image,
1403
+ bkg_lam=None,
1404
+ ):
1405
+ """
1406
+ Upload a background image for future use with the specified OB
1407
+
1408
+ bkg_image: a local FITS file
1409
+
1410
+ bkg_lam: discription of the bandpass of the image, only needed if file
1411
+ Primary header does not include a keyword BANDPASS or FILTER or is not
1412
+ a 'standard' ESO or GEMINI image.
1413
+
1414
+ returns: data
1415
+ data : None or an <ob_info> json structure, possibly with warning messages.
1416
+
1417
+ usage:
1418
+ ::
1419
+ bkg_image_info = api.bkg_image_upload(obId 'ADP.2015-05-11T10:19:53.607.fits')
1420
+ bkg_image_info = api.bkg_image_upload(obId 'my_image.fits', bkg_lam='my_special_filter:')
1421
+ """
1422
+ if os.path.exists(bkg_image) :
1423
+ files={
1424
+ 'bkg_image_fits_file': (
1425
+ os.path.basename(bkg_image),
1426
+ open(bkg_image, 'rb'),
1427
+ 'image/fits',
1428
+ {
1429
+ 'url': self.apiUrl + '/obsBlocks/%d' % obId,
1430
+ 'access_token': self.access_token,
1431
+ 'mode': "upload_bkg_image",
1432
+ 'bkg_lam': bkg_lam,
1433
+ },
1434
+ )
1435
+ }
1436
+ else :
1437
+ raise P2Error(404, 'POST', self.p2fc_url, "Could not find bkg_image %s" %(bkg_image))
1438
+
1439
+ return self.p2fc_request('POST', files=files)
1440
+
1441
+ def bkg_image_delete(
1442
+ self,
1443
+ obId,
1444
+ fits_hash,
1445
+ ):
1446
+ """
1447
+ Delete the specified image for the specified OB
1448
+
1449
+ fits_hash: as provided by e.g. bkg_image_get_info(obId)
1450
+
1451
+ returns: data
1452
+ data : None or an <ob_image_list> json structure, possibly with warning messages.
1453
+
1454
+ usage:
1455
+ ::
1456
+ bkg_image_info = api.bkg_image_delete(obId, 'user_bkg_image|00c98a6dde73f309f058378399cad019f8405603c63d74b5f2ba342a8ae0a8f1')
1457
+
1458
+ bkg_image_list = api.bkg_image_get_ob_image_list(obId)
1459
+ first_image=list(bkg_image_list.keys())[0]
1460
+ fits_hash=bkg_image_list[first_image]['fits_hash']
1461
+ bkg_image_list = api.bkg_image_delete(obId, 'user_bkg_image|%s' %(fits_hash))
1462
+ """
1463
+ ob_url=self.apiUrl + '/obsBlocks/%d' % obId
1464
+ return self.p2fc_request('DELETE', 'bkg_image_delete?url=%s&access_token=%s&fits_hash=%s' %(ob_url, self.access_token, fits_hash))
1465
+
1466
+
1467
+ def p2fc_request(self, method, url='', data=None, files=None) :
1468
+ r = self.session.request(method, "%s/%s" %(self.p2fc_url, url), data=data, files=files)
1469
+
1470
+ content_type = r.headers['Content-Type'].split(';')[0]
1471
+ # handle response
1472
+ if 200 <= r.status_code < 300:
1473
+ if content_type == 'application/json':
1474
+ data = r.json()
1475
+ return data
1476
+ return None
1477
+ elif content_type == 'application/json' and 'error' in r.json():
1478
+ raise P2Error(r.status_code, 'POST', self.p2fc_url, r.json()['error'])
1479
+ else:
1480
+ raise P2Error(r.status_code, 'POST', self.p2fc_url, r.text)
1481
+
1482
+
1483
+ def getFindingChart(self, obId, index, filename):
1484
+ """Download finding chart save it into the given filename.
1485
+
1486
+ api: GET /obsBlocks/{obId}/findingCharts/{index}
1487
+
1488
+ usage:
1489
+ ::
1490
+
1491
+ api.getFindingChart(obId, 1, 'fc1.jpg')
1492
+ """
1493
+ url = '/obsBlocks/%d/findingCharts/%d' % (obId, index)
1494
+ return self.downloadBinaryFile(url, filename)
1495
+
1496
+ def deleteFindingChart(self, obId, index):
1497
+ """Delete finding chart.
1498
+
1499
+ The finding chart at the given index is deleted.
1500
+ Finding charts with a higher index 'slide down',
1501
+ i.e. finding charts are always numbered consecutively
1502
+ starting with 1.
1503
+
1504
+ The OB must have the latest ipVersion.
1505
+
1506
+ Service Mode: The OB must have status (-) or (P).
1507
+
1508
+ api: DELETE /obsBlocks/{obId}/findingCharts/{index}
1509
+
1510
+ usage:
1511
+ ::
1512
+
1513
+ api.deleteFindingChart(obId, 1)
1514
+ """
1515
+ return self.delete('/obsBlocks/%d/findingCharts/%d' % (obId, index))
1516
+
1517
+ def duplicateFindingChart(self, obId, index, destinationObId):
1518
+ """Duplicate this finding chart to another OB.
1519
+
1520
+ Attach this finding chart to a different OB.
1521
+ The finding chart is appended at the end of the target OB's
1522
+ finding chart list.
1523
+
1524
+ The target OB must have the latest ipVersion.
1525
+
1526
+ Visitor Mode: The target OB can have any status.
1527
+
1528
+ Service Mode: The target OB must have status (-) or (P).
1529
+
1530
+ api: POST /obsBlocks/{obId}/findingCharts/{index}/duplicate
1531
+
1532
+ usage:
1533
+ ::
1534
+
1535
+ api.duplicateFindingChart(obId, 1, destinationObId)
1536
+ """
1537
+ return self.post('/obsBlocks/%d/findingCharts/%d/duplicate' % (obId, index), {'obId': destinationObId})
1538
+
1539
+ def getOBSchedulingInfo(self, obId):
1540
+ """Get OB scheduling information.
1541
+
1542
+ api: GET /obsBlocks/{obId}/schedule
1543
+
1544
+ usage:
1545
+ ::
1546
+
1547
+ obInfo, obInfoVersion = api.getOBSchedulingInfo(obId)
1548
+ """
1549
+ return self.get('/obsBlocks/%d/schedule' % obId)
1550
+
1551
+ def saveOBSchedulingInfo(self, obId, obInfo, version):
1552
+ """Update OB scheduling information.
1553
+
1554
+ api: PUT /obsBlocks/{obId}/schedule
1555
+
1556
+ usage:
1557
+ ::
1558
+
1559
+ obInfo, obInfoVersion = api.getOBSchedulingInfo(obId)
1560
+ # change something
1561
+ obInfo['targetName'] = 'My Best Target'
1562
+ api.saveOBSchedulingInfo(obId, obInfo, obInfoVersion)
1563
+ """
1564
+ return self.put('/obsBlocks/%d/schedule' % obId, obInfo, etag=version)
1565
+
1566
+ def getContainerSchedulingInfo(self, containerId):
1567
+ """Get scheduling information of a Concatenation inside a Group or TimeLink
1568
+
1569
+ api: GET /containers/{containerId}/schedule
1570
+
1571
+ usage:
1572
+ ::
1573
+
1574
+ containerInfo, containerInfoVersion = api.getContainerSchedulingInfo(containerId)
1575
+ """
1576
+ return self.get('/containers/%d/schedule' % containerId)
1577
+
1578
+ def saveContainerSchedulingInfo(self, containerId, containerInfo, version):
1579
+ """Update OB scheduling information.
1580
+
1581
+ api: PUT /containers/%d/schedule
1582
+
1583
+ usage:
1584
+ ::
1585
+
1586
+ containerInfo, containerInfoVersion = api.getContainerSchedulingInfo(containerId)
1587
+ # change something
1588
+ containerInfo['groupContribution'] = 50
1589
+ api.saveContainerSchedulingInfo(containerId, containerInfo, containerInfoVersion)
1590
+ """
1591
+ return self.put('/containers/%d/schedule' % containerId, containerInfo, etag=version)
1592
+
1593
+ def getContainerItemsSchedulingInfo(self, containerId):
1594
+ """Get scheduling information of all items (OBs or containers) in a container
1595
+
1596
+ api: GET /containers/{containerId}/items/schedule
1597
+
1598
+ usage:
1599
+ ::
1600
+
1601
+ containerInfo, containerInfoVersion = api.getContainerItemsSchedulingInfo(containerId)
1602
+ """
1603
+ return self.get('/containers/%d/items/scheduling' % containerId)
1604
+
1605
+ # ---------- VISITOR EXECUTION SEQUENCE APIs ----------
1606
+ def getExecutionSequence(self, instrument):
1607
+ """Retrieve the personal execution sequence for an instrument.
1608
+
1609
+ api: GET /executionSequences/{instrument}
1610
+
1611
+ usage:
1612
+ ::
1613
+
1614
+ executionSequence, esVersion = api.getExecutionSequence('UVES')
1615
+ """
1616
+ return self.get('/executionSequences/%s' % instrument)
1617
+
1618
+ def saveExecutionSequence(self, instrument, executionSequence, version):
1619
+ """Change personal visitor execution sequence for an instrument.
1620
+
1621
+ The list should contain objects with an obId in the desired execution
1622
+ order and may also contain OBs from delegated observing runs. Only
1623
+ visitor mode OBs in status '+', 'A', 'X' or 'S' can be included.
1624
+
1625
+ The obId is the only required field in each list member, but all fields
1626
+ sent by a GET request can be included unchanged.
1627
+
1628
+ api: PUT /executionSequences/{instrument}
1629
+
1630
+ usage:
1631
+ ::
1632
+
1633
+ executionSequence, esVersion = api.getExecutionSequence('UVES')
1634
+ executionSequence, esVersion = api.saveExecutionSequence('UVES',
1635
+ [{ 'obId': ob1 }, { 'obId': ob2 }], esVersion)
1636
+ print('OBs in UVES execution sequence', ', '.join(str(e['obId']) for e in executionSequence))
1637
+ """
1638
+ return self.put('/executionSequences/%s' % instrument, executionSequence, version)
1639
+
1640
+ # ---------- INSTRUMENT PACKAGE APIs ----------
1641
+ def getInstrumentConstraints(self, instrument, ipVersion):
1642
+ """Get all observing constraints that OBs using this instrument package
1643
+ must define.
1644
+
1645
+ The information returned is useful for building a user interface and
1646
+ also contains allowed values or ranges.
1647
+
1648
+ api: GET /instrumentPackages/{instrument}/{ipVersion}/instrumentConstraints
1649
+
1650
+ usage:
1651
+ ::
1652
+
1653
+ insConstraints, _ = api.getInstrumentConstraints('UVES', '98.09')
1654
+ """
1655
+ return self.get('/instrumentPackages/%s/%s/instrumentConstraints' % (instrument, ipVersion))
1656
+
1657
+ def getTemplateSignatures(self, instrument, ipVersion, phase='phase2' ):
1658
+ """List all template signatures that can be attached to OBs using this
1659
+ instrument package.
1660
+
1661
+ api: GET /instrumentPackages/{instrument}/{ipVersion}/templateSignatures
1662
+
1663
+ usage:
1664
+ ::
1665
+
1666
+ tplSignatures, _ = api.getTemplateSignatures('UVES', '98.09')
1667
+ tplSignatures, _ = api.getTemplateSignatures('UVES', '98.09', 'phase1')
1668
+ tplSignatures, _ = api.getTemplateSignatures('UVES', '98.09', 'phase2')
1669
+ """
1670
+ assert phase in ['phase1','phase2']
1671
+ return self.get('/instrumentPackages/%s/%s/templateSignatures?for=%s' % (instrument, ipVersion, phase))
1672
+
1673
+ def getTemplateSignature(self, instrument, ipVersion, templateName):
1674
+ """Get a specific template signature.
1675
+
1676
+ api: GET /instrumentPackages/{instrument}/{ipVersion}/templateSignatures/{templateName}
1677
+
1678
+ usage:
1679
+ ::
1680
+
1681
+ tplSignature, _ = api.getTemplateSignature('UVES', '98.08', 'UVES_blue_acq_slit')
1682
+ """
1683
+ return self.get('/instrumentPackages/%s/%s/templateSignatures/%s' % (instrument, ipVersion, templateName))
1684
+
1685
+ def getInstrumentPackageVersions(self, instrument):
1686
+ """Get list of instrument package versions.
1687
+
1688
+ api: GET /instrumentPackages/{instrument}
1689
+
1690
+ usage:
1691
+ ::
1692
+
1693
+ ipVersions, _ = api.getInstrumentPackageVersions('UVES')
1694
+ """
1695
+ return self.get('/instrumentPackages/%s/' % instrument)
1696
+
1697
+ # ---------- USER APIs ----------
1698
+ def getUser(self):
1699
+ """Get information about the logged-in user.
1700
+
1701
+ api: GET /authenticatedUser
1702
+
1703
+ usage:
1704
+ ::
1705
+
1706
+ user, _ = api.getUser()
1707
+ """
1708
+ return self.get('/authenticatedUser')
1709
+
1710
+ # ---------- private methods ----------
1711
+ # Retry Handling for requests
1712
+ # See https://www.peterbe.com/plog/best-practice-with-retries-with-requests
1713
+ # Or https://findwork.dev/blog/advanced-usage-python-requests-timeouts-retries-hooks/
1714
+ def requests_retry_session(self):
1715
+ adapter = requests.adapters.HTTPAdapter(max_retries=3)
1716
+ session = requests.Session()
1717
+ session.mount('https://', adapter)
1718
+ return session
1719
+
1720
+ def request(self, method, url, data=None, etag=None):
1721
+ self.request_count += 1
1722
+
1723
+ # configure request headers
1724
+ assert self.access_token is not None
1725
+ headers = {
1726
+ 'Authorization': 'Bearer ' + self.access_token,
1727
+ 'Accept': 'application/json'
1728
+ }
1729
+ if data is not None:
1730
+ headers['Content-Type'] = 'application/json'
1731
+ if etag is not None:
1732
+ headers['If-Match'] = etag
1733
+
1734
+ # make request
1735
+ url = self.apiUrl + url
1736
+ if self.debug:
1737
+ print(method, url, data)
1738
+ r = self.session.request(method, url, headers=headers, data=json.dumps(data))
1739
+ content_type = r.headers['Content-Type'].split(';')[0]
1740
+ etag = r.headers.get('ETag', None)
1741
+
1742
+ # handle response
1743
+ if 200 <= r.status_code < 300:
1744
+ if content_type == 'application/json':
1745
+ data = r.json()
1746
+ return data, etag
1747
+ return None, etag
1748
+ elif content_type == 'application/json' and 'error' in r.json():
1749
+ raise P2Error(r.status_code, method, url, r.json()['error'])
1750
+ else:
1751
+ raise P2Error(r.status_code, method, url, 'oops unknown error')
1752
+
1753
+ def uploadFile(self, method, url, filename, contentType, etag=None):
1754
+ basename = os.path.basename(filename)
1755
+ assert self.access_token is not None
1756
+ headers = {
1757
+ 'Authorization': 'Bearer ' + self.access_token,
1758
+ 'Content-Disposition': 'inline; filename="%s"' % basename,
1759
+ 'Content-Type': contentType
1760
+ }
1761
+ if etag is not None:
1762
+ headers['If-Match'] = etag
1763
+ with open(filename, 'rb') as f:
1764
+ r = self.session.request(method, self.apiUrl + url, data=f, headers=headers)
1765
+ if r.status_code == 201 or r.status_code == 204:
1766
+ etag = r.headers.get('ETag', None)
1767
+ return basename, etag
1768
+ else:
1769
+ content_type = r.headers['Content-Type'].split(';')[0]
1770
+ if content_type == 'application/json' and 'error' in r.json():
1771
+ raise P2Error(r.status_code, method, url, r.json()['error'])
1772
+ else:
1773
+ raise P2Error(r.status_code, method, url, 'oops unknown error')
1774
+
1775
+ def downloadTextFile(self, url, filename):
1776
+ assert self.access_token is not None
1777
+ headers = {
1778
+ 'Authorization': 'Bearer ' + self.access_token,
1779
+ 'Accept': 'text/plain'
1780
+ }
1781
+ r = self.session.request('GET', self.apiUrl + url, stream=True, headers=headers)
1782
+ with open(filename, 'wb') as f:
1783
+ for line in r.iter_lines():
1784
+ f.write(line + b'\n')
1785
+ return None, r.headers.get('ETag', None)
1786
+
1787
+ def downloadBinaryFile(self, url, filename):
1788
+ assert self.access_token is not None
1789
+ headers = {
1790
+ 'Authorization': 'Bearer ' + self.access_token
1791
+ }
1792
+ r = self.session.request('GET', self.apiUrl + url, stream=True, headers=headers)
1793
+ with open(filename, 'wb') as f:
1794
+ for chunk in r.iter_content(chunk_size=128):
1795
+ f.write(chunk)
1796
+ return None, None
1797
+
1798
+ def get(self, url):
1799
+ return self.request('GET', url)
1800
+
1801
+ def put(self, url, data, etag=None):
1802
+ return self.request('PUT', url, data, etag)
1803
+
1804
+ def post(self, url, data=None, etag=None):
1805
+ return self.request('POST', url, data, etag)
1806
+
1807
+ def delete(self, url, etag=None):
1808
+ return self.request('DELETE', url, etag=etag)