Mercurial > repos > eric-rasche > apollo
comparison webapollo.py @ 0:6002cc0df04e draft
planemo upload for repository https://github.com/TAMU-CPT/galaxy-webapollo commit 4e5a5af7689f1713c34a6ad9a9594c205e762fdd
author | eric-rasche |
---|---|
date | Tue, 03 May 2016 13:38:55 -0400 |
parents | |
children | d4ae83dedb14 |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:6002cc0df04e |
---|---|
1 import requests | |
2 import json | |
3 import collections | |
4 from BCBio import GFF | |
5 import StringIO | |
6 import logging | |
7 logging.getLogger("requests").setLevel(logging.CRITICAL) | |
8 log = logging.getLogger() | |
9 | |
10 | |
11 | |
12 class WebApolloInstance(object): | |
13 | |
14 def __init__(self, url, username, password): | |
15 self.apollo_url = url | |
16 self.username = username | |
17 self.password = password | |
18 | |
19 self.annotations = AnnotationsClient(self) | |
20 self.groups = GroupsClient(self) | |
21 self.io = IOClient(self) | |
22 self.organisms = OrganismsClient(self) | |
23 self.users = UsersClient(self) | |
24 self.metrics = MetricsClient(self) | |
25 self.bio = RemoteRecord(self) | |
26 | |
27 def __str__(self): | |
28 return '<WebApolloInstance at %s>' % self.apollo_url | |
29 | |
30 | |
31 class GroupObj(object): | |
32 def __init__(self, **kwargs): | |
33 self.name = kwargs['name'] | |
34 | |
35 if 'id' in kwargs: | |
36 self.groupId = kwargs['id'] | |
37 | |
38 | |
39 class UserObj(object): | |
40 ROLE_USER = 'USER' | |
41 ROLE_ADMIN = 'ADMIN' | |
42 | |
43 def __init__(self, **kwargs): | |
44 # Generally expect 'userId', 'firstName', 'lastName', 'username' (email) | |
45 for attr in kwargs.keys(): | |
46 setattr(self, attr, kwargs[attr]) | |
47 | |
48 if 'groups' in kwargs: | |
49 groups = [] | |
50 for groupData in kwargs['groups']: | |
51 groups.append(GroupObj(**groupData)) | |
52 self.groups = groups | |
53 | |
54 self.__props = kwargs.keys() | |
55 | |
56 | |
57 def isAdmin(self): | |
58 if hasattr(self, 'role'): | |
59 return self.role == self.ROLE_ADMIN | |
60 return False | |
61 | |
62 def refresh(self, wa): | |
63 # This method requires some sleeping usually. | |
64 newU = wa.users.loadUser(self).toDict() | |
65 for prop in newU: | |
66 setattr(self, prop, newU[prop]) | |
67 | |
68 def toDict(self): | |
69 data = {} | |
70 for prop in self.__props: | |
71 data[prop] = getattr(self, prop) | |
72 return data | |
73 | |
74 def __str__(self): | |
75 return '<User %s: %s %s <%s>>' % (self.userId, self.firstName, | |
76 self.lastName, self.username) | |
77 | |
78 | |
79 class Client(object): | |
80 | |
81 def __init__(self, webapolloinstance, **requestArgs): | |
82 self._wa = webapolloinstance | |
83 | |
84 self.__verify = requestArgs.get('verify', True) | |
85 self._requestArgs = requestArgs | |
86 | |
87 if 'verify' in self._requestArgs: | |
88 del self._requestArgs['verify'] | |
89 | |
90 def request(self, clientMethod, data, post_params={}, isJson=True): | |
91 url = self._wa.apollo_url + self.CLIENT_BASE + clientMethod | |
92 | |
93 headers = { | |
94 'Content-Type': 'application/json' | |
95 } | |
96 | |
97 data.update({ | |
98 'username': self._wa.username, | |
99 'password': self._wa.password, | |
100 }) | |
101 | |
102 r = requests.post(url, data=json.dumps(data), headers=headers, | |
103 verify=self.__verify, params=post_params, **self._requestArgs) | |
104 | |
105 if r.status_code == 200: | |
106 if isJson: | |
107 d = r.json() | |
108 if 'username' in d: | |
109 del d['username'] | |
110 if 'password' in d: | |
111 del d['password'] | |
112 return d | |
113 else: | |
114 return r.text | |
115 | |
116 # @see self.body for HTTP response body | |
117 raise Exception("Unexpected response from apollo %s: %s" % | |
118 (r.status_code, r.text)) | |
119 | |
120 def get(self, clientMethod, get_params): | |
121 url = self._wa.apollo_url + self.CLIENT_BASE + clientMethod | |
122 headers = {} | |
123 | |
124 r = requests.get(url, headers=headers, verify=self.__verify, | |
125 params=get_params, **self._requestArgs) | |
126 if r.status_code == 200: | |
127 d = r.json() | |
128 if 'username' in d: | |
129 del d['username'] | |
130 if 'password' in d: | |
131 del d['password'] | |
132 return d | |
133 # @see self.body for HTTP response body | |
134 raise Exception("Unexpected response from apollo %s: %s" % | |
135 (r.status_code, r.text)) | |
136 | |
137 | |
138 class MetricsClient(Client): | |
139 CLIENT_BASE = '/metrics/' | |
140 | |
141 def getServerMetrics(self): | |
142 return self.get('metrics', {}) | |
143 | |
144 | |
145 class AnnotationsClient(Client): | |
146 CLIENT_BASE = '/annotationEditor/' | |
147 | |
148 def _update_data(self, data): | |
149 if not hasattr(self, '_extra_data'): raise Exception("Please call setSequence first") | |
150 data.update(self._extra_data) | |
151 return data | |
152 | |
153 def setSequence(self, sequence, organism): | |
154 self._extra_data = { | |
155 'sequence': sequence, | |
156 'organism': organism, | |
157 } | |
158 | |
159 def setDescription(self, featureDescriptions): | |
160 data = { | |
161 'features': featureDescriptions, | |
162 } | |
163 data = self._update_data(data) | |
164 return self.request('setDescription', data) | |
165 | |
166 def setName(self, uniquename, name): | |
167 # TODO | |
168 data = { | |
169 'features': [ | |
170 { | |
171 'uniquename': uniquename, | |
172 'name': name, | |
173 } | |
174 ], | |
175 } | |
176 data = self._update_data(data) | |
177 return self.request('setName', data) | |
178 | |
179 def setNames(self, features): | |
180 # TODO | |
181 data = { | |
182 'features': features, | |
183 } | |
184 data = self._update_data(data) | |
185 return self.request('setName', data) | |
186 | |
187 def setStatus(self, statuses): | |
188 # TODO | |
189 data = { | |
190 'features': statuses, | |
191 } | |
192 data = self._update_data(data) | |
193 return self.request('setStatus', data) | |
194 | |
195 def setSymbol(self, symbols): | |
196 data = { | |
197 'features': symbols, | |
198 } | |
199 data.update(self._extra_data) | |
200 return self.request('setSymbol', data) | |
201 | |
202 def getComments(self, features): | |
203 data = { | |
204 'features': features, | |
205 } | |
206 data = self._update_data(data) | |
207 return self.request('getComments', data) | |
208 | |
209 def addAttribute(self, features): | |
210 data = { | |
211 'features': features, | |
212 } | |
213 data = self._update_data(data) | |
214 return self.request('addAttribute', data) | |
215 | |
216 def getFeatures(self): | |
217 data = self._update_data({}) | |
218 return self.request('getFeatures', data) | |
219 | |
220 def getSequence(self, uniquename): | |
221 data = { | |
222 'features': [ | |
223 {'uniquename': uniquename} | |
224 ] | |
225 } | |
226 data = self._update_data(data) | |
227 return self.request('getSequence', data) | |
228 | |
229 def addFeature(self, feature, trustme=False): | |
230 if not trustme: | |
231 raise NotImplementedError("Waiting on better docs from project. If you know what you are doing, pass trustme=True to this function.") | |
232 | |
233 data = {} | |
234 data.update(feature) | |
235 data = self._update_data(data) | |
236 return self.request('addFeature', data) | |
237 | |
238 def addTranscript(self, transcript, trustme=False): | |
239 if not trustme: | |
240 raise NotImplementedError("Waiting on better docs from project. If you know what you are doing, pass trustme=True to this function.") | |
241 | |
242 data = {} | |
243 data.update(transcript) | |
244 data = self._update_data(data) | |
245 return self.request('addTranscript', data) | |
246 | |
247 # addExon, add/delete/updateComments, addTranscript skipped due to docs | |
248 | |
249 def duplicateTranscript(self, transcriptId): | |
250 data = { | |
251 'features': [{'uniquename': transcriptId}] | |
252 } | |
253 | |
254 data = self._update_data(data) | |
255 return self.request('duplicateTranscript', data) | |
256 | |
257 def setTranslationStart(self, uniquename, start): | |
258 data = { | |
259 'features': [{ | |
260 'uniquename': uniquename, | |
261 'location': { | |
262 'fmin': start | |
263 } | |
264 }] | |
265 } | |
266 data = self._update_data(data) | |
267 return self.request('setTranslationStart', data) | |
268 | |
269 def setTranslationEnd(self, uniquename, end): | |
270 data = { | |
271 'features': [{ | |
272 'uniquename': uniquename, | |
273 'location': { | |
274 'fmax': end | |
275 } | |
276 }] | |
277 } | |
278 data = self._update_data(data) | |
279 return self.request('setTranslationEnd', data) | |
280 | |
281 def setLongestOrf(self, uniquename): | |
282 data = { | |
283 'features': [{ | |
284 'uniquename': uniquename, | |
285 }] | |
286 } | |
287 data = self._update_data(data) | |
288 return self.request('setLongestOrf', data) | |
289 | |
290 def setBoundaries(self, uniquename, start, end): | |
291 data = { | |
292 'features': [{ | |
293 'uniquename': uniquename, | |
294 'location': { | |
295 'fmin': start, | |
296 'fmax': end, | |
297 } | |
298 }] | |
299 } | |
300 data = self._update_data(data) | |
301 return self.request('setBoundaries', data) | |
302 | |
303 def getSequenceAlterations(self): | |
304 data = { | |
305 } | |
306 data = self._update_data(data) | |
307 return self.request('getSequenceAlterations', data) | |
308 | |
309 def setReadthroughStopCodon(self, uniquename): | |
310 data = { | |
311 'features': [{ | |
312 'uniquename': uniquename, | |
313 }] | |
314 } | |
315 data = self._update_data(data) | |
316 return self.request('setReadthroughStopCodon', data) | |
317 | |
318 def deleteSequenceAlteration(self, uniquename): | |
319 data = { | |
320 'features': [{ | |
321 'uniquename': uniquename, | |
322 }] | |
323 } | |
324 data = self._update_data(data) | |
325 return self.request('deleteSequenceAlteration', data) | |
326 | |
327 def flipStrand(self, uniquenames): | |
328 data = { | |
329 'features': [ | |
330 {'uniquename': x} for x in uniquenames | |
331 ] | |
332 } | |
333 data = self._update_data(data) | |
334 return self.request('flipStrand', data) | |
335 | |
336 def mergeExons(self, exonA, exonB): | |
337 data = { | |
338 'features': [ | |
339 {'uniquename': exonA}, | |
340 {'uniquename': exonB}, | |
341 ] | |
342 } | |
343 data = self._update_data(data) | |
344 return self.request('mergeExons', data) | |
345 | |
346 # def splitExon(): pass | |
347 | |
348 def deleteFeatures(self, uniquenames): | |
349 assert isinstance(uniquenames, collections.Iterable) | |
350 data = { | |
351 'features': [ | |
352 {'uniquename': x} for x in uniquenames | |
353 ] | |
354 } | |
355 data = self._update_data(data) | |
356 return self.request('deleteFeature', data) | |
357 | |
358 # def deleteExon(): pass | |
359 | |
360 # def makeIntron(self, uniquename, ): pass | |
361 | |
362 def getSequenceSearchTools(self): | |
363 return self.get('getSequenceSearchTools', {}) | |
364 | |
365 def getCannedComments(self): | |
366 return self.get('getCannedComments', {}) | |
367 | |
368 def searchSequence(self, searchTool, sequence, database): | |
369 data = { | |
370 'key': searchTool, | |
371 'residues': sequence, | |
372 'database_id': database, | |
373 } | |
374 return self.request('searchSequences', data) | |
375 | |
376 def getGff3(self, uniquenames): | |
377 assert isinstance(uniquenames, collections.Iterable) | |
378 data = { | |
379 'features': [ | |
380 {'uniquename': x} for x in uniquenames | |
381 ] | |
382 } | |
383 data = self._update_data(data) | |
384 return self.request('getGff3', data, isJson=False) | |
385 | |
386 | |
387 class GroupsClient(Client): | |
388 CLIENT_BASE = '/group/' | |
389 | |
390 def createGroup(self, name): | |
391 data = {'name': name} | |
392 return self.request('createGroup', data) | |
393 | |
394 def getOrganismPermissionsForGroup(self, group): | |
395 data = { | |
396 'id': group.groupId, | |
397 'name': group.name, | |
398 } | |
399 return self.request('getOrganismPermissionsForGroup', data) | |
400 | |
401 def loadGroups(self, group=None): | |
402 data ={} | |
403 if group is not None: | |
404 data['groupId'] = group.groupId | |
405 | |
406 return self.request('loadGroups', data) | |
407 | |
408 def deleteGroup(self, group): | |
409 data = { | |
410 'id': group.groupId, | |
411 'name': group.name, | |
412 } | |
413 return self.request('deleteGroup', data) | |
414 | |
415 def updateGroup(self, group, newName): | |
416 # TODO: Sure would be nice if modifying ``group.name`` would invoke | |
417 # this? | |
418 data = { | |
419 'id': group.groupId, | |
420 'name': newName, | |
421 } | |
422 return self.request('updateGroup', data) | |
423 | |
424 def updateOrganismPermission(self, group, organismName, | |
425 administrate=False, write=False, read=False, | |
426 export=False): | |
427 data = { | |
428 'groupId': group.groupId, | |
429 'name': organismName, | |
430 'administrate': administrate, | |
431 'write': write, | |
432 'export': export, | |
433 'read': read, | |
434 } | |
435 return self.request('updateOrganismPermission', data) | |
436 | |
437 def updateMembership(self, group, users): | |
438 data = { | |
439 'groupId': group.groupId, | |
440 'user': [user.email for user in users] | |
441 } | |
442 return self.request('updateMembership', data) | |
443 | |
444 | |
445 class IOClient(Client): | |
446 CLIENT_BASE = '/IOService/' | |
447 | |
448 def write(self, exportType='FASTA', seqType='peptide', | |
449 exportFormat='text', sequences=None, organism=None, | |
450 output='text', exportAllSequences=False, | |
451 exportGff3Fasta=False): | |
452 if exportType not in ('FASTA', 'GFF3'): | |
453 raise Exception("exportType must be one of FASTA, GFF3") | |
454 | |
455 if seqType not in ('peptide', 'cds', 'cdna', 'genomic'): | |
456 raise Exception("seqType must be one of peptide, cds, dna, genomic") | |
457 | |
458 if exportFormat not in ('gzip', 'text'): | |
459 raise Exception("exportFormat must be one of gzip, text") | |
460 | |
461 if output not in ('file', 'text'): | |
462 raise Exception("output must be one of file, text") | |
463 | |
464 data = { | |
465 'type': exportType, | |
466 'seqType': seqType, | |
467 'format': exportFormat, | |
468 'sequences': sequences, | |
469 'organism': organism, | |
470 'output': output, | |
471 'exportAllSequences': exportAllSequences, | |
472 'exportGff3Fasta': exportGff3Fasta, | |
473 } | |
474 | |
475 return self.request('write', data, isJson=output == 'file') | |
476 | |
477 def download(self, uuid, outputFormat='gzip'): | |
478 | |
479 if outputFormat.lower() not in ('gzip', 'text'): | |
480 raise Exception("outputFormat must be one of file, text") | |
481 | |
482 data = { | |
483 'format': outputFormat, | |
484 'uuid': uuid, | |
485 } | |
486 return self.request('write', data) | |
487 | |
488 | |
489 class OrganismsClient(Client): | |
490 CLIENT_BASE = '/organism/' | |
491 | |
492 def addOrganism(self, commonName, directory, blatdb=None, species=None, | |
493 genus=None, public=False): | |
494 data = { | |
495 'commonName': commonName, | |
496 'directory': directory, | |
497 'publicMode': public, | |
498 } | |
499 | |
500 if blatdb is not None: | |
501 data['blatdb'] = blatdb | |
502 if genus is not None: | |
503 data['genus'] = genus | |
504 if species is not None: | |
505 data['species'] = species | |
506 | |
507 return self.request('addOrganism', data) | |
508 | |
509 def findAllOrganisms(self): | |
510 return self.request('findAllOrganisms', {}) | |
511 | |
512 def findOrganismByCn(self, cn): | |
513 orgs = self.findAllOrganisms() | |
514 orgs = [x for x in orgs if x['commonName'] == cn] | |
515 if len(orgs) == 0: | |
516 raise Exception("Unknown common name") | |
517 else: | |
518 return orgs[0] | |
519 | |
520 def deleteOrganism(self, organismId): | |
521 return self.request('deleteOrganism', {'id': organismId}) | |
522 | |
523 def deleteOrganismFeatures(self, organismId): | |
524 return self.request('deleteOrganismFeatures', {'id': organismId}) | |
525 | |
526 def getSequencesForOrganism(self, commonName): | |
527 return self.request('getSequencesForOrganism', {'organism': commonName}) | |
528 | |
529 def updateOrganismInfo(self, organismId, commonName, directory, blatdb=None, species=None, genus=None, public=False): | |
530 data = { | |
531 'id': organismId, | |
532 'name': commonName, | |
533 'directory': directory, | |
534 'publicMode': public, | |
535 } | |
536 | |
537 if blatdb is not None: | |
538 data['blatdb'] = blatdb | |
539 if genus is not None: | |
540 data['genus'] = genus | |
541 if species is not None: | |
542 data['species'] = species | |
543 | |
544 return self.request('updateOrganismInfo', data) | |
545 | |
546 | |
547 class UsersClient(Client): | |
548 CLIENT_BASE = '/user/' | |
549 | |
550 def getOrganismPermissionsForUser(self, user): | |
551 data = { | |
552 'userId': user.userId, | |
553 } | |
554 return self.request('getOrganismPermissionsForUser', data) | |
555 | |
556 def updateOrganismPermission(self, user, organism, administrate=False, | |
557 write=False, export=False, read=False): | |
558 data = { | |
559 'userId': user.userId, | |
560 'organism': organism, | |
561 'ADMINISTRATE': administrate, | |
562 'WRITE': write, | |
563 'EXPORT': export, | |
564 'READ': read, | |
565 } | |
566 return self.request('updateOrganismPermission', data) | |
567 | |
568 def loadUser(self, user): | |
569 return self.loadUserById(user.userId) | |
570 | |
571 def loadUserById(self, userId): | |
572 res = self.request('loadUsers', {'userId': userId}) | |
573 if isinstance(res, list): | |
574 # We can only match one, right? | |
575 return UserObj(**res[0]) | |
576 else: | |
577 return res | |
578 | |
579 def loadUsers(self, email=None): | |
580 res = self.request('loadUsers', {}) | |
581 data = [UserObj(**x) for x in res] | |
582 if email is not None: | |
583 data = [x for x in data if x.username == email] | |
584 | |
585 return data | |
586 | |
587 def addUserToGroup(self, group, user): | |
588 data = {'group': group.name, 'userId': user.userId} | |
589 return self.request('addUserToGroup', data) | |
590 | |
591 def removeUserFromGroup(self, group, user): | |
592 data = {'group': group.name, 'userId': user.userId} | |
593 return self.request('removeUserFromGroup', data) | |
594 | |
595 def createUser(self, email, firstName, lastName, newPassword, role="user", groups=None): | |
596 data = { | |
597 'firstName': firstName, | |
598 'lastName': lastName, | |
599 'email': email, | |
600 'role': role, | |
601 'groups': [] if groups is None else groups, | |
602 # 'availableGroups': [], | |
603 'newPassword': newPassword, | |
604 # 'organismPermissions': [], | |
605 } | |
606 return self.request('createUser', data) | |
607 | |
608 def deleteUser(self, user): | |
609 return self.request('deleteUser', {'userId': user.userId}) | |
610 | |
611 def updateUser(self, user, email, firstName, lastName, newPassword): | |
612 data = { | |
613 'userId': user.userId, | |
614 'email': email, | |
615 'firstName': firstName, | |
616 'lastName': lastName, | |
617 'newPassword': newPassword, | |
618 } | |
619 return self.request('updateUser', data) | |
620 | |
621 | |
622 class RemoteRecord(Client): | |
623 CLIENT_BASE = None | |
624 | |
625 def ParseRecord(self, cn): | |
626 org = self._wa.organisms.findOrganismByCn(cn) | |
627 self._wa.annotations.setSequence(org['commonName'], org['id']) | |
628 | |
629 data = StringIO.StringIO(self._wa.io.write( | |
630 exportType='GFF3', | |
631 seqType='genomic', | |
632 exportAllSequences=False, | |
633 exportGff3Fasta=True, | |
634 output="text", | |
635 exportFormat="text", | |
636 sequences=cn, | |
637 )) | |
638 data.seek(0) | |
639 | |
640 for record in GFF.parse(data): | |
641 yield WebApolloSeqRecord(record, self._wa) | |
642 | |
643 | |
644 class WebApolloSeqRecord(object): | |
645 def __init__(self, sr, wa): | |
646 self._sr = sr | |
647 self._wa = wa | |
648 | |
649 def __dir__(self): | |
650 return dir(self._sr) | |
651 | |
652 def __getattr__(self, key): | |
653 if key in ('_sr', '_wa'): | |
654 print self.__dict__ | |
655 return self.__dict__[key] | |
656 else: | |
657 if key == 'features': | |
658 return (WebApolloSeqFeature(x, self._wa) | |
659 for x in self._sr.__dict__[key]) | |
660 else: | |
661 return self._sr.__dict__[key] | |
662 | |
663 def __setattr__(self, key, value): | |
664 if key in ('_sd', '_wa'): | |
665 self.__dict__[key] = value | |
666 else: | |
667 self._sr.__dict__[key] = value | |
668 # Methods acting on the SeqRecord object | |
669 print key, value | |
670 | |
671 | |
672 class WebApolloSeqFeature(object): | |
673 def __init__(self, sf, wa): | |
674 self._sf = sf | |
675 self._wa = wa | |
676 | |
677 def __dir__(self): | |
678 return dir(self._sf) | |
679 | |
680 def __getattr__(self, key): | |
681 if key in ('_sf', '_wa'): | |
682 return self.__dict__[key] | |
683 else: | |
684 return self._sf.__dict__[key] | |
685 | |
686 def __setattr__(self, key, value): | |
687 if key in ('_sf', '_wa'): | |
688 self.__dict__[key] = value | |
689 else: | |
690 # Methods acting on the SeqFeature object | |
691 if key == 'location': | |
692 if value.strand != self._sf.location.strand: | |
693 self.wa.annotations.flipStrand( | |
694 self._sf.qualifiers['ID'][0] | |
695 ) | |
696 | |
697 self.wa.annotations.setBoundaries( | |
698 self._sf.qualifiers['ID'][0], | |
699 value.start, | |
700 value.end, | |
701 ) | |
702 | |
703 self._sf.__dict__[key] = value | |
704 else: | |
705 self._sf.__dict__[key] = value | |
706 print key, value | |
707 | |
708 def _tnType(feature): | |
709 if feature.type in ('gene', 'mRNA', 'exon', 'CDS'): | |
710 return feature.type | |
711 else: | |
712 return 'exon' | |
713 | |
714 def _yieldFeatData(features): | |
715 for f in features: | |
716 current = { | |
717 'location': { | |
718 'strand': f.strand, | |
719 'fmin': int(f.location.start), | |
720 'fmax': int(f.location.end), | |
721 }, | |
722 'type': { | |
723 'name': _tnType(f), | |
724 'cv': { | |
725 'name': 'sequence', | |
726 } | |
727 }, | |
728 } | |
729 if f.type in ('gene', 'mRNA'): | |
730 current['name'] = f.qualifiers.get('Name', [f.id])[0] | |
731 if hasattr(f, 'sub_features') and len(f.sub_features) > 0: | |
732 current['children'] = [x for x in _yieldFeatData(f.sub_features)] | |
733 | |
734 yield current | |
735 | |
736 def featuresToFeatureSchema(features): | |
737 compiled = [] | |
738 for feature in features: | |
739 if feature.type != 'gene': | |
740 log.warn("Not able to handle %s features just yet...", feature.type) | |
741 continue | |
742 | |
743 for x in _yieldFeatData([feature]): | |
744 compiled.append(x) | |
745 return compiled |