Loading...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 | # SPDX-License-Identifier: GPL-2.0+ # # Copyright 2025 Simon Glass <sjg@chromium.org> # """Handles the patman database This uses sqlite3 with a local file. To adjsut the schema, increment LATEST, create a migrate_to_v<x>() function and write some code in migrate_to() to call it. """ from collections import namedtuple, OrderedDict import os import sqlite3 from u_boot_pylib import tools from u_boot_pylib import tout from patman.series import Series # Schema version (version 0 means there is no database yet) LATEST = 4 # Information about a series/version record SerVer = namedtuple( 'SER_VER', 'idnum,series_id,version,link,cover_id,cover_num_comments,name,' 'archive_tag') # Record from the pcommit table: # idnum (int): record ID # seq (int): Patch sequence in series (0 is first) # subject (str): patch subject # svid (int): ID of series/version record in ser_ver table # change_id (str): Change-ID value # state (str): Current status in patchwork # patch_id (int): Patchwork's patch ID for this patch # num_comments (int): Number of comments attached to the commit Pcommit = namedtuple( 'PCOMMIT', 'idnum,seq,subject,svid,change_id,state,patch_id,num_comments') class Database: """Database of information used by patman""" # dict of databases: # key: filename # value: Database object instances = {} def __init__(self, db_path): """Set up a new database object Args: db_path (str): Path to the database """ if db_path in Database.instances: # Two connections to the database can cause: # sqlite3.OperationalError: database is locked raise ValueError(f"There is already a database for '{db_path}'") self.con = None self.cur = None self.db_path = db_path self.is_open = False Database.instances[db_path] = self @staticmethod def get_instance(db_path): """Get the database instance for a path This is provides to ensure that different callers can obtain the same database object when accessing the same database file. Args: db_path (str): Path to the database Return: Database: Database instance, which is created if necessary """ db = Database.instances.get(db_path) if db: return db, False return Database(db_path), True def start(self): """Open the database read for use, migrate to latest schema""" self.open_it() self.migrate_to(LATEST) def open_it(self): """Open the database, creating it if necessary""" if self.is_open: raise ValueError('Already open') if not os.path.exists(self.db_path): tout.warning(f'Creating new database {self.db_path}') self.con = sqlite3.connect(self.db_path) self.cur = self.con.cursor() self.is_open = True def close(self): """Close the database""" if not self.is_open: raise ValueError('Already closed') self.con.close() self.cur = None self.con = None self.is_open = False def create_v1(self): """Create a database with the v1 schema""" self.cur.execute( 'CREATE TABLE series (id INTEGER PRIMARY KEY AUTOINCREMENT,' 'name UNIQUE, desc, archived BIT)') # Provides a series_id/version pair, which is used to refer to a # particular series version sent to patchwork. This stores the link # to patchwork self.cur.execute( 'CREATE TABLE ser_ver (id INTEGER PRIMARY KEY AUTOINCREMENT,' 'series_id INTEGER, version INTEGER, link,' 'FOREIGN KEY (series_id) REFERENCES series (id))') self.cur.execute( 'CREATE TABLE upstream (name UNIQUE, url, is_default BIT)') # change_id is the Change-Id # patch_id is the ID of the patch on the patchwork server self.cur.execute( 'CREATE TABLE pcommit (id INTEGER PRIMARY KEY AUTOINCREMENT,' 'svid INTEGER, seq INTEGER, subject, patch_id INTEGER, ' 'change_id, state, num_comments INTEGER, ' 'FOREIGN KEY (svid) REFERENCES ser_ver (id))') self.cur.execute( 'CREATE TABLE settings (name UNIQUE, proj_id INT, link_name)') def _migrate_to_v2(self): """Add a schema_version table""" self.cur.execute('CREATE TABLE schema_version (version INTEGER)') def _migrate_to_v3(self): """Store the number of cover-letter comments in the schema""" self.cur.execute('ALTER TABLE ser_ver ADD COLUMN cover_id') self.cur.execute('ALTER TABLE ser_ver ADD COLUMN cover_num_comments ' 'INTEGER') self.cur.execute('ALTER TABLE ser_ver ADD COLUMN name') def _migrate_to_v4(self): """Add an archive tag for each ser_ver""" self.cur.execute('ALTER TABLE ser_ver ADD COLUMN archive_tag') def migrate_to(self, dest_version): """Migrate the database to the selected version Args: dest_version (int): Version to migrate to """ while True: version = self.get_schema_version() if version == dest_version: break self.close() tools.write_file(f'{self.db_path}old.v{version}', tools.read_file(self.db_path)) version += 1 tout.info(f'Update database to v{version}') self.open_it() if version == 1: self.create_v1() elif version == 2: self._migrate_to_v2() elif version == 3: self._migrate_to_v3() elif version == 4: self._migrate_to_v4() # Save the new version if we have a schema_version table if version > 1: self.cur.execute('DELETE FROM schema_version') self.cur.execute( 'INSERT INTO schema_version (version) VALUES (?)', (version,)) self.commit() def get_schema_version(self): """Get the version of the database's schema Return: int: Database version, 0 means there is no data; anything less than LATEST means the schema is out of date and must be updated """ # If there is no database at all, assume v0 version = 0 try: self.cur.execute('SELECT name FROM series') except sqlite3.OperationalError: return 0 # If there is no schema, assume v1 try: self.cur.execute('SELECT version FROM schema_version') version = self.cur.fetchone()[0] except sqlite3.OperationalError: return 1 return version def execute(self, query, parameters=()): """Execute a database query Args: query (str): Query string parameters (list of values): Parameters to pass Return: """ return self.cur.execute(query, parameters) def commit(self): """Commit changes to the database""" self.con.commit() def rollback(self): """Roll back changes to the database""" self.con.rollback() def lastrowid(self): """Get the last row-ID reported by the database Return: int: Value for lastrowid """ return self.cur.lastrowid def rowcount(self): """Get the row-count reported by the database Return: int: Value for rowcount """ return self.cur.rowcount def _get_series_list(self, include_archived): """Get a list of Series objects from the database Args: include_archived (bool): True to include archives series Return: list of Series """ res = self.execute( 'SELECT id, name, desc FROM series ' + ('WHERE archived = 0' if not include_archived else '')) return [Series.from_fields(idnum=idnum, name=name, desc=desc) for idnum, name, desc in res.fetchall()] # series functions def series_get_dict_by_id(self, include_archived=False): """Get a dict of Series objects from the database Args: include_archived (bool): True to include archives series Return: OrderedDict: key: series ID value: Series with idnum, name and desc filled out """ sdict = OrderedDict() for ser in self._get_series_list(include_archived): sdict[ser.idnum] = ser return sdict def series_find_by_name(self, name, include_archived=False): """Find a series and return its details Args: name (str): Name to search for include_archived (bool): True to include archives series Returns: idnum, or None if not found """ res = self.execute( 'SELECT id FROM series WHERE name = ?' + ('AND archived = 0' if not include_archived else ''), (name,)) recs = res.fetchall() # This shouldn't happen assert len(recs) <= 1, 'Expected one match, but multiple found' if len(recs) != 1: return None return recs[0][0] def series_get_info(self, idnum): """Get information for a series from the database Args: idnum (int): Series ID to look up Return: tuple: str: Series name str: Series description Raises: ValueError: Series is not found """ res = self.execute('SELECT name, desc FROM series WHERE id = ?', (idnum,)) recs = res.fetchall() if len(recs) != 1: raise ValueError(f'No series found (id {idnum} len {len(recs)})') return recs[0] def series_get_dict(self, include_archived=False): """Get a dict of Series objects from the database Args: include_archived (bool): True to include archives series Return: OrderedDict: key: series name value: Series with idnum, name and desc filled out """ sdict = OrderedDict() for ser in self._get_series_list(include_archived): sdict[ser.name] = ser return sdict def series_get_version_list(self, series_idnum): """Get a list of the versions available for a series Args: series_idnum (int): ID of series to look up Return: str: List of versions, which may be empty if the series is in the process of being added """ res = self.execute('SELECT version FROM ser_ver WHERE series_id = ?', (series_idnum,)) return [x[0] for x in res.fetchall()] def series_get_max_version(self, series_idnum): """Get the highest version number available for a series Args: series_idnum (int): ID of series to look up Return: int: Maximum version number """ res = self.execute( 'SELECT MAX(version) FROM ser_ver WHERE series_id = ?', (series_idnum,)) return res.fetchall()[0][0] def series_get_all_max_versions(self): """Find the latest version of all series Return: list of: int: ser_ver ID int: series ID int: Maximum version """ res = self.execute( 'SELECT id, series_id, MAX(version) FROM ser_ver ' 'GROUP BY series_id') return res.fetchall() def series_add(self, name, desc): """Add a new series record The new record is set to not archived Args: name (str): Series name desc (str): Series description Return: int: ID num of the new series record """ self.execute( 'INSERT INTO series (name, desc, archived) ' f"VALUES ('{name}', '{desc}', 0)") return self.lastrowid() def series_remove(self, idnum): """Remove a series from the database The series must exist Args: idnum (int): ID num of series to remove """ self.execute('DELETE FROM series WHERE id = ?', (idnum,)) assert self.rowcount() == 1 def series_remove_by_name(self, name): """Remove a series from the database Args: name (str): Name of series to remove Raises: ValueError: Series does not exist (database is rolled back) """ self.execute('DELETE FROM series WHERE name = ?', (name,)) if self.rowcount() != 1: self.rollback() raise ValueError(f"No such series '{name}'") def series_set_archived(self, series_idnum, archived): """Update archive flag for a series Args: series_idnum (int): ID num of the series archived (bool): Whether to mark the series as archived or unarchived """ self.execute( 'UPDATE series SET archived = ? WHERE id = ?', (archived, series_idnum)) def series_set_name(self, series_idnum, name): """Update name for a series Args: series_idnum (int): ID num of the series name (str): new name to use """ self.execute( 'UPDATE series SET name = ? WHERE id = ?', (name, series_idnum)) # ser_ver functions def ser_ver_get_link(self, series_idnum, version): """Get the link for a series version Args: series_idnum (int): ID num of the series version (int): Version number to search for Return: str: Patchwork link as a string, e.g. '12325', or None if none Raises: ValueError: Multiple matches are found """ res = self.execute( 'SELECT link FROM ser_ver WHERE ' f"series_id = {series_idnum} AND version = '{version}'") recs = res.fetchall() if not recs: return None if len(recs) > 1: raise ValueError('Expected one match, but multiple matches found') return recs[0][0] def ser_ver_set_link(self, series_idnum, version, link): """Set the link for a series version Args: series_idnum (int): ID num of the series version (int): Version number to search for link (str): Patchwork link for the ser_ver Return: bool: True if the record was found and updated, else False """ if link is None: link = '' self.execute( 'UPDATE ser_ver SET link = ? WHERE series_id = ? AND version = ?', (str(link), series_idnum, version)) return self.rowcount() != 0 def ser_ver_set_info(self, info): """Set the info for a series version Args: info (SER_VER): Info to set. Only two options are supported: 1: svid,cover_id,cover_num_comments,name 2: svid,name Return: bool: True if the record was found and updated, else False """ assert info.idnum is not None if info.cover_id: assert info.series_id is None self.execute( 'UPDATE ser_ver SET cover_id = ?, cover_num_comments = ?, ' 'name = ? WHERE id = ?', (info.cover_id, info.cover_num_comments, info.name, info.idnum)) else: assert not info.cover_id assert not info.cover_num_comments assert not info.series_id assert not info.version assert not info.link self.execute('UPDATE ser_ver SET name = ? WHERE id = ?', (info.name, info.idnum)) return self.rowcount() != 0 def ser_ver_set_version(self, svid, version): """Sets the version for a ser_ver record Args: svid (int): Record ID to update version (int): Version number to add Raises: ValueError: svid was not found """ self.execute( 'UPDATE ser_ver SET version = ? WHERE id = ?', (version, svid)) if self.rowcount() != 1: raise ValueError(f'No ser_ver updated (svid {svid})') def ser_ver_set_archive_tag(self, svid, tag): """Sets the archive tag for a ser_ver record Args: svid (int): Record ID to update tag (tag): Tag to add Raises: ValueError: svid was not found """ self.execute( 'UPDATE ser_ver SET archive_tag = ? WHERE id = ?', (tag, svid)) if self.rowcount() != 1: raise ValueError(f'No ser_ver updated (svid {svid})') def ser_ver_add(self, series_idnum, version, link=None): """Add a new ser_ver record Args: series_idnum (int): ID num of the series which is getting a new version version (int): Version number to add link (str): Patchwork link, or None if not known Return: int: ID num of the new ser_ver record """ self.execute( 'INSERT INTO ser_ver (series_id, version, link) VALUES (?, ?, ?)', (series_idnum, version, link)) return self.lastrowid() def ser_ver_get_for_series(self, series_idnum, version=None): """Get a list of ser_ver records for a given series ID Args: series_idnum (int): ID num of the series to search version (int): Version number to search for, or None for all Return: SER_VER: Requested information Raises: ValueError: There is no matching idnum/version """ base = ('SELECT id, series_id, version, link, cover_id, ' 'cover_num_comments, name, archive_tag FROM ser_ver ' 'WHERE series_id = ?') if version: res = self.execute(base + ' AND version = ?', (series_idnum, version)) else: res = self.execute(base, (series_idnum,)) recs = res.fetchall() if not recs: raise ValueError( f'No matching series for id {series_idnum} version {version}') if version: return SerVer(*recs[0]) return [SerVer(*x) for x in recs] def ser_ver_get_ids_for_series(self, series_idnum, version=None): """Get a list of ser_ver records for a given series ID Args: series_idnum (int): ID num of the series to search version (int): Version number to search for, or None for all Return: list of int: List of svids for the matching records """ if version: res = self.execute( 'SELECT id FROM ser_ver WHERE series_id = ? AND version = ?', (series_idnum, version)) else: res = self.execute( 'SELECT id FROM ser_ver WHERE series_id = ?', (series_idnum,)) return list(res.fetchall()[0]) def ser_ver_get_list(self): """Get a list of patchwork entries from the database Return: list of SER_VER """ res = self.execute( 'SELECT id, series_id, version, link, cover_id, ' 'cover_num_comments, name, archive_tag FROM ser_ver') items = res.fetchall() return [SerVer(*x) for x in items] def ser_ver_remove(self, series_idnum, version=None, remove_pcommits=True, remove_series=True): """Delete a ser_ver record Removes the record which has the given series ID num and version Args: series_idnum (int): ID num of the series version (int): Version number, or None to remove all versions remove_pcommits (bool): True to remove associated pcommits too remove_series (bool): True to remove the series if versions is None """ if remove_pcommits: # Figure out svids to delete svids = self.ser_ver_get_ids_for_series(series_idnum, version) self.pcommit_delete_list(svids) if version: self.execute( 'DELETE FROM ser_ver WHERE series_id = ? AND version = ?', (series_idnum, version)) else: self.execute( 'DELETE FROM ser_ver WHERE series_id = ?', (series_idnum,)) if not version and remove_series: self.series_remove(series_idnum) # pcommit functions def pcommit_get_list(self, find_svid=None): """Get a dict of pcommits entries from the database Args: find_svid (int): If not None, finds the records associated with a particular series and version; otherwise returns all records Return: list of PCOMMIT: pcommit records """ query = ('SELECT id, seq, subject, svid, change_id, state, patch_id, ' 'num_comments FROM pcommit') if find_svid is not None: query += f' WHERE svid = {find_svid}' res = self.execute(query) return [Pcommit(*rec) for rec in res.fetchall()] def pcommit_add_list(self, svid, pcommits): """Add records to the pcommit table Args: svid (int): ser_ver ID num pcommits (list of PCOMMIT): Only seq, subject, change_id are uses; svid comes from the argument passed in and the others are assumed to be obtained from patchwork later """ for pcm in pcommits: self.execute( 'INSERT INTO pcommit (svid, seq, subject, change_id) VALUES ' '(?, ?, ?, ?)', (svid, pcm.seq, pcm.subject, pcm.change_id)) def pcommit_delete(self, svid): """Delete pcommit records for a given ser_ver ID Args_: svid (int): ser_ver ID num of records to delete """ self.execute('DELETE FROM pcommit WHERE svid = ?', (svid,)) def pcommit_delete_list(self, svid_list): """Delete pcommit records for a given set of ser_ver IDs Args_: svid (list int): ser_ver ID nums of records to delete """ vals = ', '.join([str(x) for x in svid_list]) self.execute('DELETE FROM pcommit WHERE svid IN (?)', (vals,)) def pcommit_update(self, pcm): """Update a pcommit record Args: pcm (PCOMMIT): Information to write; only the idnum, state, patch_id and num_comments are used Return: True if the data was written """ self.execute( 'UPDATE pcommit SET ' 'patch_id = ?, state = ?, num_comments = ? WHERE id = ?', (pcm.patch_id, pcm.state, pcm.num_comments, pcm.idnum)) return self.rowcount() > 0 # upstream functions def upstream_add(self, name, url): """Add a new upstream record Args: name (str): Name of the tree url (str): URL for the tree Raises: ValueError if the name already exists in the database """ try: self.execute( 'INSERT INTO upstream (name, url) VALUES (?, ?)', (name, url)) except sqlite3.IntegrityError as exc: if 'UNIQUE constraint failed: upstream.name' in str(exc): raise ValueError(f"Upstream '{name}' already exists") from exc def upstream_set_default(self, name): """Mark (only) the given upstream as the default Args: name (str): Name of the upstream remote to set as default, or None Raises: ValueError if more than one name matches (should not happen); database is rolled back """ self.execute("UPDATE upstream SET is_default = 0") if name is not None: self.execute( 'UPDATE upstream SET is_default = 1 WHERE name = ?', (name,)) if self.rowcount() != 1: self.rollback() raise ValueError(f"No such upstream '{name}'") def upstream_get_default(self): """Get the name of the default upstream Return: str: Default-upstream name, or None if there is no default """ res = self.execute( "SELECT name FROM upstream WHERE is_default = 1") recs = res.fetchall() if len(recs) != 1: return None return recs[0][0] def upstream_delete(self, name): """Delete an upstream target Args: name (str): Name of the upstream remote to delete Raises: ValueError: Upstream does not exist (database is rolled back) """ self.execute(f"DELETE FROM upstream WHERE name = '{name}'") if self.rowcount() != 1: self.rollback() raise ValueError(f"No such upstream '{name}'") def upstream_get_dict(self): """Get a list of upstream entries from the database Return: OrderedDict: key (str): upstream name value (str): url """ res = self.execute('SELECT name, url, is_default FROM upstream') udict = OrderedDict() for name, url, is_default in res.fetchall(): udict[name] = url, is_default return udict # settings functions def settings_update(self, name, proj_id, link_name): """Set the patchwork settings of the project Args: name (str): Name of the project to use in patchwork proj_id (int): Project ID for the project link_name (str): Link name for the project """ self.execute('DELETE FROM settings') self.execute( 'INSERT INTO settings (name, proj_id, link_name) ' 'VALUES (?, ?, ?)', (name, proj_id, link_name)) def settings_get(self): """Get the patchwork settings of the project Returns: tuple or None if there are no settings: name (str): Project name, e.g. 'U-Boot' proj_id (int): Patchworks project ID for this project link_name (str): Patchwork's link-name for the project """ res = self.execute("SELECT name, proj_id, link_name FROM settings") recs = res.fetchall() if len(recs) != 1: return None return recs[0] |