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]
|