Coverage for python/lsst/summit/utils/consdbClient.py: 76%

205 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-27 02:34 -0700

1# This file is part of summit_utils. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <http://www.gnu.org/licenses/>. 

21 

22import logging 

23import os 

24from collections.abc import Mapping 

25from dataclasses import dataclass 

26from typing import Any 

27from urllib.parse import quote, urlparse 

28 

29import numpy as np 

30import requests 

31from astropy.table import Column, Table 

32 

33__all__ = ["ConsDbClient", "FlexibleMetadataInfo", "getCcdVisitTableForDay", "getWideQuicklookTableForDay"] 

34 

35 

36logger = logging.getLogger(__name__) 

37 

38# Timeouts in seconds applied to every request made by ConsDbClient. 

39# The read timeout is generous because some queries are genuinely slow. 

40DEFAULT_CONNECT_TIMEOUT: float = 10.0 

41DEFAULT_READ_TIMEOUT: float = 600.0 

42 

43 

44def _urljoin(*args: str) -> str: 

45 """Join parts of a URL with slashes. 

46 

47 Does not do any quoting. Mostly to remove a level of list-making. 

48 

49 Parameters 

50 ---------- 

51 *args : `str` 

52 Each parameter is a URL part. 

53 

54 Returns 

55 ------- 

56 url : `str` 

57 The joined URL. 

58 """ 

59 return "/".join(args) 

60 

61 

62def _check_status(r: requests.Response) -> None: 

63 """Check the status of an HTTP response and raise if an error. 

64 

65 Adds additional response information to the raise_for_status exception. 

66 

67 Parameters 

68 ---------- 

69 r : `requests.Response` 

70 The response to check. 

71 

72 Raises 

73 ------ 

74 requests.HTTPError 

75 Raised if a non-successful status is returned. 

76 """ 

77 try: 

78 r.raise_for_status() 

79 except requests.HTTPError as e: 

80 try: 

81 # Use ``r`` rather than ``e.response``: raise_for_status always 

82 # attaches this response, but its type is Response | None. 

83 json_data = r.json() 

84 e.add_note(str(json_data)) 

85 if "message" in json_data: 85 ↛ 89line 85 didn't jump to line 89 because the condition on line 85 was always true

86 e.add_note(f"\n\n{json_data['message']}") 

87 except requests.JSONDecodeError: 

88 pass 

89 raise e 

90 

91 

92def clean_url(resp: requests.Response, *args: Any, **kwargs: Any) -> requests.Response: 

93 """Parse url from response and remove netloc portion. 

94 

95 Set new url in response and return response 

96 

97 Parameters 

98 ---------- 

99 resp : `requests.Response` 

100 The response that could contain a URL with tokens 

101 """ 

102 url = urlparse(resp.url) 

103 short_user = f"{url.username[:2]}***" if url.username is not None else "" 

104 if url.password is not None: 

105 pwd = url.password 

106 short_pass = ":gt***" if pwd.startswith("gt-") else ":xxxxxxx" 

107 else: 

108 short_pass = "" 

109 netloc = f"{short_user}{short_pass}@{url.hostname}" 

110 resp.url = url._replace(netloc=netloc).geturl() 

111 return resp 

112 

113 

114@dataclass 

115class FlexibleMetadataInfo: 

116 """Description of a flexible metadata value. 

117 

118 Parameters 

119 ---------- 

120 dtype : `str` 

121 Data type of the flexible metadata value. 

122 One of ``bool``, ``int``, ``float``, or ``str``. 

123 doc : `str` 

124 Documentation string. 

125 unit : `str`, optional 

126 Unit of the value. 

127 ucd : `str`, optional 

128 IVOA Unified Content Descriptor 

129 (https://www.ivoa.net/documents/UCD1+/). 

130 """ 

131 

132 dtype: str 

133 doc: str 

134 unit: str | None = None 

135 ucd: str | None = None 

136 

137 

138class ConsDbClient: 

139 """A client library for accessing the Consolidated Database. 

140 

141 This library provides a basic interface for using flexible metadata 

142 (key/value pairs associated with observation ids from an observation 

143 type table), determining the schema of ConsDB tables, querying the 

144 ConsDB using a general SQL SELECT statement, and inserting into 

145 ConsDB tables. 

146 

147 Parameters 

148 ---------- 

149 url : `str` | `None` 

150 Base URL of the Web service, defaults to the value of environment 

151 variable ``LSST_CONSDB_PQ_URL`` (the location of the publish/query 

152 service). 

153 token : `str` | `None` 

154 Authentication token for the RSP. The token must begin with "gt-". 

155 If `None`, the ``ACCESS_TOKEN`` environment variable will be 

156 checked and used if available. 

157 connect_timeout : `float` | `None` 

158 Seconds to wait for the connection to be established, defaults to 

159 `DEFAULT_CONNECT_TIMEOUT`. 

160 read_timeout : `float` | `None` 

161 Seconds to wait between bytes sent by the server, defaults to 

162 `DEFAULT_READ_TIMEOUT`. Pass `None` to wait indefinitely, which is 

163 rarely what you want. 

164 

165 Notes 

166 ----- 

167 This client is a thin layer over the publish/query Web service, which 

168 avoids having a dependency on database drivers. 

169 

170 It enforces the return of query results as Astropy Tables. 

171 """ 

172 

173 def __init__( 

174 self, 

175 url: str | None = None, 

176 token: str | None = None, 

177 connect_timeout: float | None = DEFAULT_CONNECT_TIMEOUT, 

178 read_timeout: float | None = DEFAULT_READ_TIMEOUT, 

179 ): 

180 self.session = requests.Session() 

181 self.session.hooks["response"].append(clean_url) 

182 self.connect_timeout = connect_timeout 

183 self.read_timeout = read_timeout 

184 

185 if token is None: 

186 token = os.environ.get("ACCESS_TOKEN") or None 

187 if token is not None: 

188 if token.startswith("gt-"): 

189 logger.debug("Using authentication token from the ACCESS_TOKEN environment variable") 

190 else: 

191 token = None 

192 logger.warning("Ignoring ACCESS_TOKEN environment variable: it does not start with `gt-`") 

193 

194 if token is not None: 

195 if not token.startswith("gt-"): 

196 raise ValueError("token must start with `gt-`.") 

197 

198 self.session.headers.update({"Authorization": f"Bearer {token}"}) 

199 

200 if url is None: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true

201 self.url = os.environ["LSST_CONSDB_PQ_URL"] 

202 else: 

203 self.url = url 

204 self.url = self.url.rstrip("/") 

205 

206 @property 

207 def timeout(self) -> tuple[float | None, float | None]: 

208 """The ``(connect, read)`` timeout pair passed to requests 

209 (`tuple` [ `float` | `None`, `float` | `None` ], read-only). 

210 """ 

211 return (self.connect_timeout, self.read_timeout) 

212 

213 def _handle_get(self, url: str, query: dict[str, str | list[str]] | None = None) -> Any: 

214 """Submit GET requests to the server. 

215 

216 Parameters 

217 ---------- 

218 url : `str` 

219 URL to GET. 

220 query : `dict` [`str`, `str` | `list` [`str`]], optional 

221 Query parameters to attach to the URL. 

222 

223 Raises 

224 ------ 

225 requests.RequestException 

226 Raised if any kind of connection error occurs. 

227 requests.Timeout 

228 Raised if the request exceeds the client timeout. 

229 requests.HTTPError 

230 Raised if a non-successful status is returned. 

231 requests.JSONDecodeError 

232 Raised if the result does not decode as JSON. 

233 

234 Returns 

235 ------- 

236 result : `Any` 

237 Result of decoding the Web service result content as JSON. 

238 """ 

239 logger.debug(f"GET {url}") 

240 response = self.session.get(url, params=query, timeout=self.timeout) 

241 _check_status(response) 

242 return response.json() 

243 

244 def _handle_post(self, url: str, data: dict[str, Any]) -> requests.Response: 

245 """Submit POST requests to the server. 

246 

247 Parameters 

248 ---------- 

249 url : `str` 

250 URL to POST. 

251 data : `dict` [`str`, `Any`] 

252 Key/value pairs of data to POST. 

253 

254 Raises 

255 ------ 

256 requests.RequestException 

257 Raised if any kind of connection error occurs. 

258 requests.Timeout 

259 Raised if the request exceeds the client timeout. 

260 requests.HTTPError 

261 Raised if a non-successful status is returned. 

262 

263 Returns 

264 ------- 

265 result : `requests.Response` 

266 The raw Web service result object. 

267 """ 

268 logger.debug(f"POST {url}: {data}") 

269 response = self.session.post(url, json=data, timeout=self.timeout) 

270 _check_status(response) 

271 return response 

272 

273 @staticmethod 

274 def compute_flexible_metadata_table_name(instrument: str, obs_type: str) -> str: 

275 """Compute the name of a flexible metadata table. 

276 

277 Each instrument and observation type made with that instrument can 

278 have a flexible metadata table. This function is useful when 

279 issuing SQL queries, and it avoids a round-trip to the server. 

280 

281 Parameters 

282 ---------- 

283 instrument : `str` 

284 Name of the instrument (e.g. ``LATISS``). 

285 obs_type : `str` 

286 Name of the observation type (e.g. ``Exposure``). 

287 

288 Returns 

289 ------- 

290 table_name : `str` 

291 Name of the appropriate flexible metadata table. 

292 """ 

293 return f"cdb_{instrument}.{obs_type}_flexdata" 

294 

295 @staticmethod 

296 def compute_fixed_metadata_namespace(instrument: str) -> str: 

297 """Compute the namespace for a fixed metadata table. 

298 

299 Each instrument has its own namespace in the ConsDB. 

300 This function is useful when issuing SQL queries, and it avoids a 

301 round-trip to the server. 

302 

303 Parameters 

304 ---------- 

305 instrument : `str` 

306 Name of the instrument (e.g. ``LATISS``). 

307 

308 Returns 

309 ------- 

310 namespace_name : `str` 

311 Name of the appropriate namespace 

312 """ 

313 return f"cdb_{instrument}" 

314 

315 def add_flexible_metadata_key( 

316 self, 

317 instrument: str, 

318 obs_type: str, 

319 key: str, 

320 dtype: str, 

321 doc: str, 

322 unit: str | None = None, 

323 ucd: str | None = None, 

324 ) -> requests.Response: 

325 """Add a key to a flexible metadata table. 

326 

327 Parameters 

328 ---------- 

329 instrument : `str` 

330 Name of the instrument (e.g. ``LATISS``). 

331 obs_type : `str` 

332 Name of the observation type (e.g. ``Exposure``). 

333 key : `str` 

334 Name of the key to be added (must not already exist). 

335 dtype : `str` 

336 One of ``bool``, ``int``, ``float``, or ``str``. 

337 doc : `str` 

338 Documentation string for the key. 

339 unit : `str`, optional 

340 Unit for the value. Should be from the IVOA 

341 (https://www.ivoa.net/documents/VOUnits/) or astropy. 

342 ucd : `str`, optional 

343 IVOA Unified Content Descriptor 

344 (https://www.ivoa.net/documents/UCD1+/). 

345 

346 Returns 

347 ------- 

348 response : `requests.Response` 

349 HTTP response from the server, with 200 status for success. 

350 

351 Raises 

352 ------ 

353 requests.RequestException 

354 Raised if any kind of connection error occurs. 

355 requests.HTTPError 

356 Raised if a non-successful status is returned. 

357 """ 

358 data = {"key": key, "dtype": dtype, "doc": doc} 

359 if unit is not None: 359 ↛ 360line 359 didn't jump to line 360 because the condition on line 359 was never true

360 data["unit"] = unit 

361 if ucd is not None: 361 ↛ 362line 361 didn't jump to line 362 because the condition on line 361 was never true

362 data["ucd"] = ucd 

363 url = _urljoin(self.url, "flex", quote(instrument), quote(obs_type), "addkey") 

364 return self._handle_post(url, data) 

365 

366 def get_flexible_metadata_keys(self, instrument: str, obs_type: str) -> dict[str, FlexibleMetadataInfo]: 

367 """Retrieve the valid keys for a flexible metadata table. 

368 

369 Parameters 

370 ---------- 

371 instrument : `str` 

372 Name of the instrument (e.g. ``LATISS``). 

373 obs_type : `str` 

374 Name of the observation type (e.g. ``Exposure``). 

375 

376 Returns 

377 ------- 

378 key_info : `dict` [ `str`, `FlexibleMetadataInfo` ] 

379 Dict of keys and information values. 

380 

381 Raises 

382 ------ 

383 requests.RequestException 

384 Raised if any kind of connection error occurs. 

385 requests.HTTPError 

386 Raised if a non-successful status is returned. 

387 """ 

388 url = _urljoin(self.url, "flex", quote(instrument), quote(obs_type), "schema") 

389 result = self._handle_get(url) 

390 return {key: FlexibleMetadataInfo(*value) for key, value in result.items()} 

391 

392 def get_flexible_metadata( 

393 self, instrument: str, obs_type: str, obs_id: int, keys: list[str] | None = None 

394 ) -> dict[str, Any]: 

395 """Get the flexible metadata for an observation. 

396 

397 Parameters 

398 ---------- 

399 instrument : `str` 

400 Name of the instrument (e.g. ``LATISS``). 

401 obs_type : `str` 

402 Name of the observation type (e.g. ``Exposure``). 

403 obs_id : `int` 

404 Unique observation id. 

405 keys : `list` [ `str` ], optional 

406 List of keys to be retrieved; all if not specified. 

407 

408 Returns 

409 ------- 

410 result_dict : `dict` [ `str`, `Any` ] 

411 Dictionary of key/value pairs for the observation. 

412 

413 Raises 

414 ------ 

415 requests.RequestException 

416 Raised if any kind of connection error occurs. 

417 requests.HTTPError 

418 Raised if a non-successful status is returned. 

419 """ 

420 url = _urljoin( 

421 self.url, 

422 "flex", 

423 quote(instrument), 

424 quote(obs_type), 

425 "obs", 

426 quote(str(obs_id)), 

427 ) 

428 return self._handle_get(url, {"k": keys} if keys else None) 

429 

430 def get_all_metadata( 

431 self, instrument: str, obs_type: str, obs_id: int, flex: bool = False 

432 ) -> dict[str, Any]: 

433 """Get all metadata for an observation. 

434 

435 Parameters 

436 ---------- 

437 instrument : `str` 

438 Name of the instrument (e.g. ``LATISS``). 

439 obs_type : `str` 

440 Name of the observation type (e.g. ``Exposure``). 

441 obs_id : `int` 

442 Unique observation id. 

443 flex : `bool` 

444 Include flexible metadata. 

445 

446 Returns 

447 ------- 

448 result_dict : `dict` [ `str`, `Any` ] 

449 Dictionary of key/value pairs for the observation. 

450 

451 Raises 

452 ------ 

453 requests.RequestException 

454 Raised if any kind of connection error occurs. 

455 requests.HTTPError 

456 Raised if a non-successful status is returned. 

457 """ 

458 url = _urljoin( 

459 self.url, 

460 "query", 

461 quote(instrument), 

462 quote(obs_type), 

463 "obs", 

464 quote(str(obs_id)), 

465 ) 

466 return self._handle_get(url, {"flex": "1"} if flex else None) 

467 

468 def insert_flexible_metadata( 

469 self, 

470 instrument: str, 

471 obs_type: str, 

472 obs_id: int, 

473 values: dict[str, Any] | None = None, 

474 *, 

475 allow_update: bool = False, 

476 **kwargs: Any, 

477 ) -> requests.Response: 

478 """Set flexible metadata values for an observation. 

479 

480 Parameters 

481 ---------- 

482 instrument : `str` 

483 Name of the instrument (e.g. ``LATISS``). 

484 obs_type : `str` 

485 Name of the observation type (e.g. ``Exposure``). 

486 obs_id : `int` 

487 Unique observation id. 

488 values : `dict` [ `str`, `Any` ], optional 

489 Dictionary of key/value pairs to add for the observation. 

490 allow_update : `bool`, optional 

491 If ``True``, allow replacement of values of existing keys. 

492 **kwargs : `dict` 

493 Additional key/value pairs, overriding ``values``. 

494 

495 Returns 

496 ------- 

497 response : `requests.Response` 

498 HTTP response from the server, with 200 status for success. 

499 

500 Raises 

501 ------ 

502 ValueError 

503 Raised if no values are provided in ``values`` or kwargs. 

504 requests.RequestException 

505 Raised if any kind of connection error occurs. 

506 requests.HTTPError 

507 Raised if a non-successful status is returned. 

508 """ 

509 if values: 

510 values.update(kwargs) 

511 else: 

512 values = kwargs 

513 if not values: 

514 raise ValueError(f"No values to set for {instrument} {obs_type} {obs_id}") 

515 data = {"values": values} 

516 url = _urljoin( 

517 self.url, 

518 "flex", 

519 quote(instrument), 

520 quote(obs_type), 

521 "obs", 

522 quote(str(obs_id)), 

523 "update", 

524 ) 

525 if allow_update: 

526 url += "?u=1" 

527 return self._handle_post(url, data) 

528 

529 def insert( 

530 self, 

531 instrument: str, 

532 table: str, 

533 obs_id: tuple[int, int] | tuple[int, int, int] | int, 

534 values: Mapping[str, Any], 

535 *, 

536 allow_update: bool = False, 

537 **kwargs: Any, 

538 ) -> requests.Response: 

539 """Insert values into a single ConsDB fixed metadata table. 

540 

541 Parameters 

542 ---------- 

543 instrument : `str` 

544 Name of the instrument (e.g. ``LATISS``). 

545 table : `str` 

546 Name of the table to insert into. 

547 obs_id : `tuple`[`int`, `int`] | `tuple`[`int`, `int`, `int`] | `int` 

548 How to address the row to insert: 

549 

550 - `int`: the natural observation id (e.g. ``exposure_id``, 

551 ``ccdexposure_id``), targeting the ``.../obs/{obs_id}`` 

552 endpoint. 

553 - 2-tuple ``(day_obs, seq_num)``: targeting the 

554 ``.../by_seq_num/{day_obs}/{seq_num}`` endpoint, for 

555 exposure-level tables. 

556 - 3-tuple ``(day_obs, seq_num, detector)``: targeting the 

557 ``.../by_seq_num/{day_obs}/{seq_num}/{detector}`` endpoint, 

558 for ccdexposure-level (per-detector) tables. 

559 values : `Mapping` [ `str`, `Any` ] 

560 Dictionary-like mapping of column/value pairs to add for the 

561 observation. 

562 allow_update : `bool`, optional 

563 If ``True``, allow replacement of values of existing columns. 

564 **kwargs : `dict` 

565 Additional column/value pairs, overriding ``values``. 

566 

567 Returns 

568 ------- 

569 response : `requests.Response` 

570 HTTP response from the server, with 200 status for success. 

571 

572 Raises 

573 ------ 

574 ValueError 

575 Raised if no values are provided in ``values`` or kwargs. 

576 requests.RequestException 

577 Raised if any kind of connection error occurs. 

578 requests.HTTPError 

579 Raised if a non-successful status is returned. 

580 """ 

581 # Build a new merged dict to avoid mutating the incoming Mapping. 

582 merged_values: dict[str, Any] = {**(dict(values) if values else {}), **kwargs} 

583 if not merged_values: 

584 raise ValueError(f"No values to insert for {instrument} {table} {obs_id}") 

585 

586 data: dict[str, Any] 

587 if isinstance(obs_id, tuple): 

588 assert len(obs_id) in ( 

589 2, 

590 3, 

591 ), f"obs_id tuple must be (day_obs, seq_num) or (day_obs, seq_num, detector); got {obs_id!r}" 

592 

593 data = {"table": table, "values": merged_values} 

594 url = _urljoin( 

595 self.url, 

596 "insert", 

597 quote(instrument), 

598 quote(table), 

599 "by_seq_num", 

600 # Two segments for exposure-level tables, three when a 

601 # detector is supplied for ccdexposure-level tables. 

602 *(quote(str(part)) for part in obs_id), 

603 ) 

604 else: 

605 data = {"table": table, "obs_id": obs_id, "values": merged_values} 

606 url = _urljoin( 

607 self.url, 

608 "insert", 

609 quote(instrument), 

610 quote(table), 

611 "obs", 

612 quote(str(obs_id)), 

613 ) 

614 if allow_update: 

615 url += "?u=1" 

616 return self._handle_post(url, data) 

617 

618 def insert_multiple( 

619 self, 

620 instrument: str, 

621 table: str, 

622 obs_dict: dict[int, dict[str, Any]], 

623 *, 

624 allow_update: bool = False, 

625 ) -> requests.Response: 

626 """Insert values into a single ConsDB fixed metadata table. 

627 

628 Parameters 

629 ---------- 

630 instrument : `str` 

631 Name of the instrument (e.g. ``LATISS``). 

632 table : `str` 

633 Name of the table to insert into. 

634 obs_dict : `dict` [ `int`, `dict` [ `str`, `Any` ] ] 

635 Dictionary of observation ids, each with a dictionary of 

636 column/value pairs to add for each observation. 

637 allow_update : `bool`, optional 

638 If ``True``, allow replacement of values of existing columns. 

639 

640 Returns 

641 ------- 

642 response : `requests.Response` 

643 HTTP response from the server, with 200 status for success. 

644 

645 Raises 

646 ------ 

647 ValueError 

648 Raised if no values are provided in ``obs_dict``. 

649 requests.RequestException 

650 Raised if any kind of connection error occurs. 

651 requests.HTTPError 

652 Raised if a non-successful status is returned. 

653 """ 

654 if not obs_dict: 

655 raise ValueError(f"No values to insert for {instrument} {table}") 

656 data = {"table": table, "obs_dict": obs_dict} 

657 url = _urljoin( 

658 self.url, 

659 "insert", 

660 quote(instrument), 

661 quote(table), 

662 ) 

663 if allow_update: 

664 url += "?u=1" 

665 return self._handle_post(url, data) 

666 

667 def query(self, query: str) -> Table: 

668 """Query the ConsDB database. 

669 

670 Parameters 

671 ---------- 

672 query : `str` 

673 A SQL query (currently) to the database. 

674 

675 Returns 

676 ------- 

677 result : `Table` 

678 An ``astropy.Table`` containing the query results. 

679 

680 Raises 

681 ------ 

682 requests.RequestException 

683 Raised if any kind of connection error occurs. 

684 requests.HTTPError 

685 Raised if a non-successful status is returned. 

686 

687 Notes 

688 ----- 

689 This is a very general query interface because it is expected that 

690 a wide variety of types of queries will be needed. If some types prove 

691 to be common, syntactic sugar could be added to make them simpler. 

692 """ 

693 url = _urljoin(self.url, "query") 

694 data = {"query": query} 

695 result = self._handle_post(url, data).json() 

696 

697 columns = result.get("columns", []) 

698 if not columns: 698 ↛ 700line 698 didn't jump to line 700 because the condition on line 698 was never true

699 # No result columns 

700 return Table(rows=[]) 

701 

702 rows = result.get("data", []) 

703 if not rows: 

704 # No result rows 

705 return Table(names=columns) 

706 

707 return Table(rows=rows, names=columns) 

708 

709 def schema( 

710 self, instrument: str | None = None, table: str | None = None 

711 ) -> dict[str, tuple[str, str]] | list[str]: 

712 """Retrieve information about ConsDB. 

713 

714 If ``instrument`` and ``table`` are given, return the schema of a 

715 fixed metadata table in ConsDB. 

716 

717 If only ``instrument`` is given, return the names of all tables 

718 for that instrument. 

719 

720 If no arguments are given, return the names of all instruments. 

721 

722 Parameters 

723 ---------- 

724 instrument : `str`, optional 

725 Name of the instrument (e.g. ``LATISS``). 

726 table : `str`, optional 

727 Name of the table to insert into. 

728 

729 Returns 

730 ------- 

731 info : `list` [ `str` ] or `dict` [ `str`, `tuple` [ `str`, `str` ] ] 

732 A list of instrument strings or table names, or else a dict of 

733 columns with values that are tuples containing a data type string 

734 and a documentation string. 

735 

736 Raises 

737 ------ 

738 ValueError 

739 Raised if only ``table`` is given. 

740 requests.RequestException 

741 Raised if any kind of connection error occurs. 

742 requests.HTTPError 

743 Raised if a non-successful status is returned. 

744 

745 Notes 

746 ----- 

747 Fixed metadata data types may use the full database vocabulary, 

748 unlike flexible metadata data types. 

749 """ 

750 if instrument is None: 

751 if table is not None: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true

752 raise ValueError("Must specify instrument if table is given") 

753 url = _urljoin(self.url, "schema") 

754 elif table is None: 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true

755 url = _urljoin(self.url, "schema", quote(instrument)) 

756 else: 

757 url = _urljoin(self.url, "schema", quote(instrument), quote(table)) 

758 result = self._handle_get(url) 

759 if instrument is not None and table is not None: 

760 return {key: (str(value[0]), str(value[1])) for key, value in result.items()} 

761 else: 

762 return [str(value) for value in result] 

763 

764 

765def getCcdVisitTableForDay( 

766 client: ConsDbClient, 

767 dayObs: int, 

768 visitTableItems: list[str] | None = None, 

769 detectors: list[int] | None = None, 

770 withZeropoint: bool = False, 

771) -> Table: 

772 """Get the ccdvisit1_quicklook table for a given dayObs. 

773 

774 Parameters 

775 ---------- 

776 client : `ConsDbClient` 

777 The ConsDbClient to use. 

778 dayObs : `int` 

779 The dayObs to query for. 

780 visitTableItems : `list` of `str`, optional 

781 Additional items from the visit1 table to include. 

782 detectors : `list` of `int`, optional 

783 If given, only return rows for these detectors. 

784 withZeropoint : `bool`, optional 

785 If ``True``, only return rows with a non-null zeropoint. 

786 

787 Returns 

788 ------- 

789 table : `astropy.table.Table` 

790 The resulting table. 

791 """ 

792 # ``cvq.*`` already pulls in every column of ccdvisit1_quicklook, so any 

793 # column we also name explicitly from ccdvisit1/visit1 has to be dropped 

794 # from the SELECT when the quicklook table itself already defines it. 

795 # ConsDB has been denormalising identity columns (visit_id, detector, 

796 # seq_num, ...) onto the quicklook tables, and selecting such a column 

797 # twice makes the server return duplicate column names, which astropy then 

798 # refuses to build a Table from. getWideQuicklookTableForDay does the same 

799 # dedup for the visit1 join. 

800 cvqCols = set(client.query("SELECT * FROM cdb_LSSTCam.ccdvisit1_quicklook LIMIT 0").colnames) 

801 

802 visitItems = ["band", "exp_time", "seq_num", "day_obs", "img_type"] 

803 if visitTableItems: 803 ↛ 804line 803 didn't jump to line 804 because the condition on line 803 was never true

804 visitItems += visitTableItems 

805 

806 selectClauses = ["cvq.*"] 

807 claimed = set(cvqCols) # cvq.* already provides all of these names 

808 for col in ("detector", "visit_id"): 

809 if col not in claimed: 

810 selectClauses.append(f"cv.{col}") 

811 claimed.add(col) 

812 for col in visitItems: 

813 if col not in claimed: 

814 selectClauses.append(f"v.{col}") 

815 claimed.add(col) 

816 

817 query = ( 

818 f"SELECT {', '.join(selectClauses)} " 

819 "FROM cdb_LSSTCam.ccdvisit1_quicklook as cvq, " 

820 "cdb_LSSTCam.ccdvisit1 as cv, " 

821 "cdb_LSSTCam.visit1 as v " 

822 ) 

823 where = f"WHERE cvq.ccdvisit_id=cv.ccdvisit_id and cv.visit_id=v.visit_id and v.day_obs={dayObs}" 

824 if detectors: 824 ↛ 825line 824 didn't jump to line 825 because the condition on line 824 was never true

825 where += f" and cv.detector in ({','.join([str(d) for d in detectors])})" 

826 if withZeropoint: 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true

827 where += " and cvq.zero_point is not null" 

828 

829 table = client.query(query + where) 

830 return table 

831 

832 

833def columnsEqual(a: Column, b: Column) -> bool: 

834 """Check if two columns are equal, taking masks into account. 

835 

836 Parameters 

837 ---------- 

838 a : `Column` 

839 First column to compare. 

840 b : `Column` 

841 Second column to compare. 

842 

843 Returns 

844 ------- 

845 equal : `bool` 

846 True if the columns are equal, False otherwise. 

847 """ 

848 aArr = np.asanyarray(a) 

849 bArr = np.asanyarray(b) 

850 if aArr.shape != bArr.shape: 

851 return False 

852 

853 aMask = getattr(a, "mask", None) 

854 bMask = getattr(b, "mask", None) 

855 

856 if aMask is None and bMask is None: 

857 return bool(np.all(aArr == bArr)) 

858 

859 if aMask is None: 

860 aMask = np.zeros(aArr.shape, dtype=bool) 

861 if bMask is None: 

862 bMask = np.zeros(bArr.shape, dtype=bool) 

863 

864 aMaskArr = np.asanyarray(aMask) 

865 bMaskArr = np.asanyarray(bMask) 

866 

867 if np.any(aMaskArr ^ bMaskArr): 

868 return False # one masked where the other isn't 

869 

870 present = ~aMaskArr 

871 return bool(np.all(aArr[present] == bArr[present])) 

872 

873 

874def getWideQuicklookTableForDay(client: ConsDbClient, dayObs: int) -> Table: 

875 """Get a wide quicklook table for a given dayObs. 

876 

877 Joins all columns from the visit1 table to the visit1_quicklook table. Note 

878 that the visit1 table already contains all the columns from the exposure 

879 table, and is just keyed by the exposure_id instead of the visit_id. 

880 

881 Parameters 

882 ---------- 

883 client : `ConsDbClient` 

884 The ConsDbClient to use. 

885 dayObs : `int` 

886 The dayObs to query for. 

887 

888 Returns 

889 ------- 

890 table : `astropy.table.Table` 

891 The resulting wide quicklook table. 

892 """ 

893 vqCols = set(client.query("SELECT * FROM cdb_LSSTCam.visit1_quicklook LIMIT 0").colnames) 

894 vCols = set(client.query("SELECT * FROM cdb_LSSTCam.visit1 LIMIT 0").colnames) 

895 

896 vOnlyCols = vCols - vqCols # exclude visit_id and all duplicates 

897 

898 selectClauses = ["vq.*"] + [f"v.{col}" for col in sorted(vOnlyCols)] 

899 

900 query = f""" 

901 SELECT {', '.join(selectClauses)} 

902 FROM cdb_LSSTCam.visit1_quicklook vq 

903 INNER JOIN cdb_LSSTCam.visit1 v USING (visit_id) 

904 WHERE vq.day_obs = {dayObs} 

905 """ 

906 return client.query(query)