sparclclient 1.2.1.dev6__tar.gz → 1.2.2b1__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 (39) hide show
  1. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/.gitignore +1 -3
  2. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/PKG-INFO +2 -2
  3. sparclclient-1.2.2b1/README.md +2 -0
  4. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/pyproject.toml +4 -5
  5. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/requirements-internal.txt +8 -5
  6. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/source/conf.py +1 -4
  7. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/Results.py +11 -2
  8. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/__init__.py +3 -10
  9. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/client.py +111 -24
  10. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/exceptions.py +3 -1
  11. sparclclient-1.2.2b1/sparcl/notebooks/sparcl-examples.ipynb +1533 -0
  12. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/utils.py +13 -0
  13. sparclclient-1.2.2b1/tests/expected_pat.py +170 -0
  14. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/tests/tests_api.py +15 -11
  15. sparclclient-1.2.1.dev6/README.md +0 -2
  16. sparclclient-1.2.1.dev6/tests/expected_pat.py +0 -114
  17. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/.github/workflows/django.yml +0 -0
  18. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/.pre-commit-config.yaml +0 -0
  19. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/.readthedocs.yaml +0 -0
  20. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/LICENSE +0 -0
  21. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/Makefile +0 -0
  22. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/make.bat +0 -0
  23. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/requirements-client.txt +0 -0
  24. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/requirements.txt +0 -0
  25. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/source/index.rst +0 -0
  26. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/source/sparcl.rst +0 -0
  27. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/benchmarks/__init__.py +0 -0
  28. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/benchmarks/benchmarks.py +0 -0
  29. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/conf.py +0 -0
  30. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/fields.py +0 -0
  31. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/gather_2d.py +0 -0
  32. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/resample_spectra.py +0 -0
  33. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/sparc.ini +0 -0
  34. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/type_conversion.py +0 -0
  35. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/sparcl/unsupported.py +0 -0
  36. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/tests/expected_dev1.py +0 -0
  37. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/tests/methods_tests.py +0 -0
  38. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/tests/utils.py +0 -0
  39. {sparclclient-1.2.1.dev6 → sparclclient-1.2.2b1}/tox.ini +0 -0
@@ -1,7 +1,5 @@
1
1
  OBSOLETE_*/
2
-
3
- # Jupyter notebook files (should be elsewhere)
4
- *.ipynb
2
+ UNPUBLISHED/
5
3
 
6
4
  # Byte-compiled / optimized / DLL files
7
5
  __pycache__/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sparclclient
3
- Version: 1.2.1.dev6
3
+ Version: 1.2.2b1
4
4
  Summary: A client for getting spectra and meta-data from NOIRLab.
5
5
  Author-email: "S. Pothier" <datalab-spectro@noirlab.edu>
6
6
  Description-Content-Type: text/markdown
@@ -10,5 +10,5 @@ Project-URL: Documentation, https://sparclclient.readthedocs.io/en/latest/
10
10
  Project-URL: Homepage, https://github.com/pypa/sparclclient
11
11
 
12
12
  # sparclclient
13
- Python Client for SPARC (SPectra Analysis and Retrievable Catalog Lab)
13
+ Python Client for SPARCL (SPectra Analysis and Retrievable Catalog Lab)
14
14
 
@@ -0,0 +1,2 @@
1
+ # sparclclient
2
+ Python Client for SPARCL (SPectra Analysis and Retrievable Catalog Lab)
@@ -1,14 +1,12 @@
1
1
  # See:
2
- # https://flit.pypa.io/en/latest/pyproject_toml.html # FLIT
3
- # https://packaging.python.org/en/latest/tutorials/packaging-projects/
4
- # https://pip.pypa.io/en/stable/reference/build-system/pyproject-toml/
2
+ # FLIT: https://flit.pypa.io/en/latest/pyproject_toml.html
5
3
  # TOML format: https://toml.io/en/
6
- # https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
4
+
7
5
  #
8
6
  # Updating PyPi:
9
7
  # source venv/bin/activate
10
8
  # flit build
11
- # flit publish
9
+ # flit publish --repository sparclclient
12
10
 
13
11
 
14
12
  [build-system]
@@ -24,6 +22,7 @@ authors = [
24
22
  readme = "README.md"
25
23
  license = {file = "LICENSE"}
26
24
  classifiers = ["License :: OSI Approved :: MIT License"]
25
+ # version is found in: ~/sandbox/sparclclient/sparcl/__init__.py
27
26
  dynamic = ["version", "description"]
28
27
 
29
28
  [project.urls]
@@ -1,4 +1,4 @@
1
- #pandas==1.3.3 # ==1.2.3
1
+ #
2
2
  #psutil==5.8.0 # for benchmarks
3
3
  #! speedtest # for benchmarks
4
4
  # packaging # for noaodatalab
@@ -11,9 +11,12 @@
11
11
  Sphinx # ==4.1.2
12
12
  sphinx-rtd-theme # ==0.5.2
13
13
  pre-commit
14
- build
15
14
  pip
16
- setuptools
17
- wheel
18
- twine
15
+ flit
19
16
  sphinx_mdinclude
17
+
18
+ #
19
+ pandas
20
+ matplotlib
21
+ ipympl
22
+ astropy
@@ -14,12 +14,9 @@
14
14
  import os
15
15
  import re
16
16
  import sys
17
-
18
- sys.path.insert(0, os.path.abspath(".."))
19
-
20
-
21
17
  from sparcl import __version__
22
18
 
19
+ sys.path.insert(0, os.path.abspath(".."))
23
20
 
24
21
  # -- Project information -----------------------------------------------------
25
22
 
@@ -21,6 +21,15 @@ class Results(UserList):
21
21
  self.fields = client.fields
22
22
  self.to_science_fields()
23
23
 
24
+ # HACK 12/14/2023 -sp- to fix UUID problem presumably
25
+ # produced on stack version upgrade (to Django 4.2, postgres 13+)
26
+ # Done per AB for expediency since real solution will be easier
27
+ # after field-renaming is removed.
28
+ for rec in self.recs:
29
+ if "sparcl_id" in rec:
30
+ rec["sparcl_id"] = str(rec["sparcl_id"])
31
+ # END __init__()
32
+
24
33
  # https://docs.python.org/3/library/collections.html#collections.deque.clear
25
34
  def clear(self):
26
35
  """Delete the contents of this collection."""
@@ -132,9 +141,9 @@ class Results(UserList):
132
141
  # Transform science fields to internal fields
133
142
  new_recs = self.science_to_internal_fields()
134
143
  # Get the ids or specids from retrieved records
135
- if type(ids_og[0]) == str:
144
+ if type(ids_og[0]) is str:
136
145
  ids_re = [f["sparcl_id"] for f in new_recs]
137
- elif type(ids_og[0]) == int:
146
+ elif type(ids_og[0]) is int:
138
147
  ids_re = [f["specid"] for f in new_recs]
139
148
  # Enumerate the original ids
140
149
  dict_og = {x: i for i, x in enumerate(ids_og)}
@@ -26,16 +26,9 @@ __all__ = ["client", "align_records"]
26
26
 
27
27
  # must mach: [N!]N(.N)*[{a|b|rc}N][.postN][.devN]
28
28
  # Example of a correct version string: '0.4.0a3.dev35'
29
- # __version__ = '0.4.0b1.dev8'
30
- # __version__ = '0.4.0b1.dev10'
31
- # __version__ = '1.0.0'
32
- # __version__ = '1.0.0b1.dev7'
33
- # __version__ = '1.0.0b1.dev8'
34
- # __version__ = '1.0.0b1.dev9'
35
- # __version__ = '1.0.1b2.dev1'
36
- # __version__ = '1.1rc1'
37
- # __version__ = '1.1rc2'
38
29
  # __version__ = '1.1'
39
30
  # __version__ = '1.2.0b4'
40
31
  # __version__ = '1.2.0' # Release
41
- __version__ = "1.2.1.dev6"
32
+ # __version__ = "1.2.1b3"
33
+ #__version__ = "1.2.1"
34
+ __version__ = "1.2.2b1"
@@ -2,6 +2,14 @@
2
2
  This module interfaces to the SPARC-Server to get spectra data.
3
3
  """
4
4
  # python -m unittest tests.tests_api
5
+
6
+ # ### Run tests against DEV
7
+ # serverurl=http://localhost:8050 python -m unittest tests.tests_api
8
+ #
9
+ # ### Run tests Against PAT Server.
10
+ # export serverurl=https://sparc1.datalab.noirlab.edu/
11
+ # python -m unittest tests.tests_api
12
+
5
13
  #
6
14
  # Doctest example:
7
15
  # cd ~/sandbox/sparclclient
@@ -14,6 +22,7 @@ This module interfaces to the SPARC-Server to get spectra data.
14
22
  from urllib.parse import urlencode, urlparse
15
23
  from warnings import warn
16
24
  import pickle
25
+ import getpass
17
26
 
18
27
  #!from pathlib import Path
19
28
  import tempfile
@@ -22,6 +31,9 @@ import tempfile
22
31
  # External Packages
23
32
  import requests
24
33
 
34
+ #!from requests.auth import HTTPBasicAuth
35
+ from requests.auth import AuthBase
36
+
25
37
  ############################################
26
38
  # Local Packages
27
39
  from sparcl.fields import Fields
@@ -102,6 +114,19 @@ RESERVED = set([DEFAULT, ALL])
102
114
  #! return set(lists[0]).intersection(*lists[1:])
103
115
 
104
116
 
117
+ class TokenAuth(AuthBase):
118
+ """Attaches HTTP Token Authentication to the given Request object."""
119
+
120
+ def __init__(self, token):
121
+ # setup any auth-related data here
122
+ self.token = token
123
+
124
+ def __call__(self, request):
125
+ # modify and return the request
126
+ request.headers["Authorization"] = self.token
127
+ return request
128
+
129
+
105
130
  ###########################
106
131
  # ## The Client class
107
132
 
@@ -113,7 +138,7 @@ class SparclClient: # was SparclApi()
113
138
  about the Client and Server that is usefule to Developers.
114
139
 
115
140
  Args:
116
- url (:obj:`str`, optional): Base URL of SPARC Server. Defaults
141
+ url (:obj:`str`, optional): Base URL of SPARCL Server. Defaults
117
142
  to 'https://astrosparcl.datalab.noirlab.edu'.
118
143
 
119
144
  verbose (:obj:`bool`, optional): Default verbosity is set to
@@ -142,8 +167,6 @@ class SparclClient: # was SparclApi()
142
167
  def __init__(
143
168
  self,
144
169
  *,
145
- email=None,
146
- password=None,
147
170
  url=_PROD,
148
171
  verbose=False,
149
172
  show_curl=False,
@@ -153,11 +176,11 @@ class SparclClient: # was SparclApi()
153
176
  """Create client instance."""
154
177
  session = requests.Session()
155
178
  self.session = session
156
-
157
- self.session.auth = (email, password) if email and password else None
179
+ self.session.auth = None
158
180
  self.rooturl = url.rstrip("/") # eg. "http://localhost:8050"
159
181
  self.apiurl = f"{self.rooturl}/sparc"
160
182
  self.apiversion = None
183
+ self.token = None
161
184
  self.verbose = verbose
162
185
  self.show_curl = show_curl # Show CURL equivalent of client method
163
186
  #!self.internal_names = internal_names
@@ -214,13 +237,73 @@ class SparclClient: # was SparclApi()
214
237
  f"(sparclclient:{self.clientversion},"
215
238
  f" api:{self.apiversion},"
216
239
  f" {self.apiurl},"
240
+ f" client_hash={ut.githash()},"
217
241
  f" verbose={self.verbose},"
218
242
  f" connect_timeout={self.c_timeout},"
219
243
  f" read_timeout={self.r_timeout})"
220
244
  )
221
245
 
246
+ def login(self, email, password=None):
247
+ if email is None: # "logout"
248
+ old_email = self.session.auth[0] if self.session.auth else None
249
+ self.session.auth = None
250
+ self.token = None
251
+ print(
252
+ f"Logged-out successfully. "
253
+ f" Previously logged-in with email {old_email}."
254
+ )
255
+ return None
256
+ if password is None:
257
+ password = getpass.getpass()
258
+ # url = f"{self.apiurl}/get_token/"
259
+ url = "http://localhost:8060/api/get_token/"
260
+ res = requests.post(
261
+ url,
262
+ json=dict(email=email, password=password),
263
+ timeout=self.timeout,
264
+ )
265
+ self.session.auth = None
266
+ self.token = None
267
+ try:
268
+ res.raise_for_status()
269
+ #!print(f"DBG: {res.content=}")
270
+ self.token = res.json()
271
+ self.session.auth = (email, password)
272
+ except Exception as err:
273
+ msg = f"Could not login with given credentials. {err=}"
274
+ return msg
275
+
276
+ print(f"Logged in successfully with {email=}")
277
+ return None
278
+
279
+ def logout(self):
280
+ return self.login(None)
281
+
282
+ @property
283
+ def authorized(self):
284
+ auth = TokenAuth(self.token) if self.token else None
285
+ username = self.session.auth[0] if self.session.auth else "Anonymous"
286
+ response = requests.get(
287
+ f"{self.apiurl}/auth_status/", auth=auth, timeout=self.timeout
288
+ )
289
+ auth_status = response.json()
290
+ print(f"{auth_status=}")
291
+
292
+ all_private_drs = set(auth_status.get("All_Private_DataReleases"))
293
+ all_drs = self.fields.all_drs
294
+ auth_drs = set(auth_status.get("Authorized_DataReleases"))
295
+ res = dict(
296
+ Loggedin_As=username, # email
297
+ Authorized_Datasets=auth_drs,
298
+ Unauthorized_Datasets=all_private_drs - auth_drs,
299
+ All_Private_Datasets=all_private_drs,
300
+ All_Datasets=all_drs,
301
+ )
302
+ return res
303
+
222
304
  @property
223
305
  def all_datasets(self):
306
+ """Set of all DataSets available from Server"""
224
307
  return self.fields.all_drs
225
308
 
226
309
  def get_default_fields(self, *, dataset_list=None):
@@ -232,7 +315,7 @@ class SparclClient: # was SparclApi()
232
315
  dataset_list (:obj:`list`, optional): List of data sets from
233
316
  which to get the default fields. Defaults to None, which
234
317
  will return the intersection of default fields in all
235
- data sets hosted on the SPARC database.
318
+ data sets hosted on the SPARCL database.
236
319
 
237
320
  Returns:
238
321
  List of fields tagged as 'default' from DATASET_LIST.
@@ -263,7 +346,7 @@ class SparclClient: # was SparclApi()
263
346
  dataset_list (:obj:`list`, optional): List of data sets from
264
347
  which to get all fields. Defaults to None, which
265
348
  will return the intersection of all fields in all
266
- data sets hosted on the SPARC database.
349
+ data sets hosted on the SPARCL database.
267
350
 
268
351
  Returns:
269
352
  List of fields tagged as 'all' from DATASET_LIST.
@@ -321,7 +404,7 @@ class SparclClient: # was SparclApi()
321
404
  dataset_list (:obj:`list`, optional): List of data sets from
322
405
  which to get available fields. Defaults to None, which
323
406
  will return the intersection of all available fields in
324
- all data sets hosted on the SPARC database.
407
+ all data sets hosted on the SPARCL database.
325
408
 
326
409
  Returns:
327
410
  Set of fields available from data sets in DATASET_LIST.
@@ -363,12 +446,14 @@ class SparclClient: # was SparclApi()
363
446
  outfields=None,
364
447
  *,
365
448
  constraints={}, # dict(fname) = [op, param, ...]
366
- # dataset_list=None,
449
+ #! exclude_unauth = True, # Not implemented yet
367
450
  limit=500,
368
451
  sort=None,
452
+ # count=False,
453
+ # dataset_list=None,
369
454
  verbose=None,
370
455
  ):
371
- """Find records in the SPARC database.
456
+ """Find records in the SPARCL database.
372
457
 
373
458
  Args:
374
459
  outfields (:obj:`list`, optional): List of fields to return.
@@ -428,6 +513,7 @@ class SparclClient: # was SparclApi()
428
513
  }
429
514
  uparams = dict(
430
515
  limit=limit,
516
+ #! count='Y' if count else 'N'
431
517
  )
432
518
  if sort is not None:
433
519
  uparams["sort"] = sort
@@ -444,7 +530,8 @@ class SparclClient: # was SparclApi()
444
530
  cmd = ut.curl_find_str(sspec, self.rooturl, qstr=qstr)
445
531
  print(cmd)
446
532
 
447
- res = requests.post(url, json=sspec, timeout=self.timeout)
533
+ auth = TokenAuth(self.token) if self.token else None
534
+ res = requests.post(url, json=sspec, auth=auth, timeout=self.timeout)
448
535
 
449
536
  if res.status_code != 200:
450
537
  if verbose and ("traceback" in res.json()):
@@ -460,14 +547,14 @@ class SparclClient: # was SparclApi()
460
547
  self, uuid_list, *, dataset_list=None, countOnly=False, verbose=False
461
548
  ):
462
549
  """Return the subset of sparcl_ids in the given uuid_list that are
463
- NOT stored in the SPARC database.
550
+ NOT stored in the SPARCL database.
464
551
 
465
552
  Args:
466
553
  uuid_list (:obj:`list`): List of sparcl_ids.
467
554
 
468
555
  dataset_list (:obj:`list`, optional): List of data sets from
469
556
  which to find missing sparcl_ids. Defaults to None, meaning
470
- all data sets hosted on the SPARC database.
557
+ all data sets hosted on the SPARCL database.
471
558
 
472
559
  countOnly (:obj:`bool`, optional): Set to True to return only
473
560
  a count of the missing sparcl_ids from the uuid_list.
@@ -478,7 +565,7 @@ class SparclClient: # was SparclApi()
478
565
 
479
566
  Returns:
480
567
  A list of the subset of sparcl_ids in the given uuid_list that
481
- are NOT stored in the SPARC database.
568
+ are NOT stored in the SPARCL database.
482
569
 
483
570
  Example:
484
571
  >>> client = SparclClient()
@@ -513,14 +600,14 @@ class SparclClient: # was SparclApi()
513
600
  self, specid_list, *, dataset_list=None, countOnly=False, verbose=False
514
601
  ):
515
602
  """Return the subset of specids in the given specid_list that are
516
- NOT stored in the SPARC database.
603
+ NOT stored in the SPARCL database.
517
604
 
518
605
  Args:
519
606
  specid_list (:obj:`list`): List of specids.
520
607
 
521
608
  dataset_list (:obj:`list`, optional): List of data sets from
522
609
  which to find missing specids. Defaults to None, meaning
523
- all data sets hosted on the SPARC database.
610
+ all data sets hosted on the SPARCL database.
524
611
 
525
612
  countOnly (:obj:`bool`, optional): Set to True to return only
526
613
  a count of the missing specids from the specid_list.
@@ -531,7 +618,7 @@ class SparclClient: # was SparclApi()
531
618
 
532
619
  Returns:
533
620
  A list of the subset of specids in the given specid_list that
534
- are NOT stored in the SPARC database.
621
+ are NOT stored in the SPARCL database.
535
622
 
536
623
  Example:
537
624
  >>> client = SparclClient(url=_PAT)
@@ -597,7 +684,7 @@ class SparclClient: # was SparclApi()
597
684
  limit=500,
598
685
  verbose=None,
599
686
  ):
600
- """Retrieve spectra records from the SPARC database by list of
687
+ """Retrieve spectra records from the SPARCL database by list of
601
688
  sparcl_ids.
602
689
 
603
690
  Args:
@@ -609,7 +696,7 @@ class SparclClient: # was SparclApi()
609
696
 
610
697
  dataset_list (:obj:`list`, optional): List of data sets from
611
698
  which to retrieve spectra data. Defaults to None, meaning all
612
- data sets hosted on the SPARC database.
699
+ data sets hosted on the SPARCL database.
613
700
 
614
701
  limit (:obj:`int`, optional): Maximum number of records to
615
702
  return. Defaults to 500. Maximum allowed is 24,000.
@@ -702,9 +789,8 @@ class SparclClient: # was SparclApi()
702
789
  print(cmd)
703
790
 
704
791
  try:
705
- res = requests.post(
706
- url, json=ids, auth=self.session.auth, timeout=self.timeout
707
- )
792
+ auth = TokenAuth(self.token) if self.token else None
793
+ res = requests.post(url, json=ids, auth=auth, timeout=self.timeout)
708
794
  except requests.exceptions.ConnectTimeout as reCT:
709
795
  raise ex.UnknownSparcl(f"ConnectTimeout: {reCT}")
710
796
  except requests.exceptions.ReadTimeout as reRT:
@@ -781,7 +867,8 @@ class SparclClient: # was SparclApi()
781
867
  limit=500,
782
868
  verbose=False,
783
869
  ):
784
- """Retrieve spectra records from the SPARC database by list of specids.
870
+ """Retrieve spectra records from the SPARCL database by list of
871
+ specids.
785
872
 
786
873
  Args:
787
874
  specid_list (:obj:`list`): List of specids.
@@ -792,7 +879,7 @@ class SparclClient: # was SparclApi()
792
879
 
793
880
  dataset_list (:obj:`list`, optional): List of data sets from
794
881
  which to retrieve spectra data. Defaults to None, meaning all
795
- data sets hosted on the SPARC database.
882
+ data sets hosted on the SPARCL database.
796
883
 
797
884
  limit (:obj:`int`, optional): Maximum number of records to
798
885
  return. Defaults to 500. Maximum allowed is 24,000.
@@ -23,7 +23,9 @@ def genSparclException(response, verbose=False):
23
23
  return BadSearchConstraint(status.get("errorMessage"))
24
24
  else:
25
25
  return UnknownServerError(
26
- f"{status.get('errorMessage')} " f"[{status.get('errorCode')}]"
26
+ f"{status.get('errorMessage')} "
27
+ f"[{status.get('errorCode')}] "
28
+ f"{status.get('traceback')}"
27
29
  )
28
30
 
29
31