Simon Glass | 2c266d8 | 2025-04-29 07:22:13 -0600 | [diff] [blame] | 1 | # SPDX-License-Identifier: GPL-2.0+ |
| 2 | # |
| 3 | # Copyright 2025 Simon Glass <sjg@chromium.org> |
| 4 | # |
| 5 | """Provides a basic API for the patchwork server |
| 6 | """ |
| 7 | |
| 8 | import asyncio |
Simon Glass | 232eefd | 2025-04-29 07:22:14 -0600 | [diff] [blame^] | 9 | import re |
Simon Glass | 2c266d8 | 2025-04-29 07:22:13 -0600 | [diff] [blame] | 10 | |
| 11 | import aiohttp |
| 12 | |
| 13 | # Number of retries |
| 14 | RETRIES = 3 |
| 15 | |
| 16 | # Max concurrent request |
| 17 | MAX_CONCURRENT = 50 |
| 18 | |
Simon Glass | 232eefd | 2025-04-29 07:22:14 -0600 | [diff] [blame^] | 19 | # Patches which are part of a multi-patch series are shown with a prefix like |
| 20 | # [prefix, version, sequence], for example '[RFC, v2, 3/5]'. All but the last |
| 21 | # part is optional. This decodes the string into groups. For single patches |
| 22 | # the [] part is not present: |
| 23 | # Groups: (ignore, ignore, ignore, prefix, version, sequence, subject) |
| 24 | RE_PATCH = re.compile(r'(\[(((.*),)?(.*),)?(.*)\]\s)?(.*)$') |
| 25 | |
| 26 | # This decodes the sequence string into a patch number and patch count |
| 27 | RE_SEQ = re.compile(r'(\d+)/(\d+)') |
| 28 | |
| 29 | |
| 30 | class Patch(dict): |
| 31 | """Models a patch in patchwork |
| 32 | |
| 33 | This class records information obtained from patchwork |
| 34 | |
| 35 | Some of this information comes from the 'Patch' column: |
| 36 | |
| 37 | [RFC,v2,1/3] dm: Driver and uclass changes for tiny-dm |
| 38 | |
| 39 | This shows the prefix, version, seq, count and subject. |
| 40 | |
| 41 | The other properties come from other columns in the display. |
| 42 | |
| 43 | Properties: |
| 44 | pid (str): ID of the patch (typically an integer) |
| 45 | seq (int): Sequence number within series (1=first) parsed from sequence |
| 46 | string |
| 47 | count (int): Number of patches in series, parsed from sequence string |
| 48 | raw_subject (str): Entire subject line, e.g. |
| 49 | "[1/2,v2] efi_loader: Sort header file ordering" |
| 50 | prefix (str): Prefix string or None (e.g. 'RFC') |
| 51 | version (str): Version string or None (e.g. 'v2') |
| 52 | raw_subject (str): Raw patch subject |
| 53 | subject (str): Patch subject with [..] part removed (same as commit |
| 54 | subject) |
| 55 | """ |
| 56 | def __init__(self, pid): |
| 57 | super().__init__() |
| 58 | self.id = pid # Use 'id' to match what the Rest API provides |
| 59 | self.seq = None |
| 60 | self.count = None |
| 61 | self.prefix = None |
| 62 | self.version = None |
| 63 | self.raw_subject = None |
| 64 | self.subject = None |
| 65 | |
| 66 | # These make us more like a dictionary |
| 67 | def __setattr__(self, name, value): |
| 68 | self[name] = value |
| 69 | |
| 70 | def __getattr__(self, name): |
| 71 | return self[name] |
| 72 | |
| 73 | def __hash__(self): |
| 74 | return hash(frozenset(self.items())) |
| 75 | |
| 76 | def __str__(self): |
| 77 | return self.raw_subject |
| 78 | |
| 79 | def parse_subject(self, raw_subject): |
| 80 | """Parse the subject of a patch into its component parts |
| 81 | |
| 82 | See RE_PATCH for details. The parsed info is placed into seq, count, |
| 83 | prefix, version, subject |
| 84 | |
| 85 | Args: |
| 86 | raw_subject (str): Subject string to parse |
| 87 | |
| 88 | Raises: |
| 89 | ValueError: the subject cannot be parsed |
| 90 | """ |
| 91 | self.raw_subject = raw_subject.strip() |
| 92 | mat = RE_PATCH.search(raw_subject.strip()) |
| 93 | if not mat: |
| 94 | raise ValueError(f"Cannot parse subject '{raw_subject}'") |
| 95 | self.prefix, self.version, seq_info, self.subject = mat.groups()[3:] |
| 96 | mat_seq = RE_SEQ.match(seq_info) if seq_info else False |
| 97 | if mat_seq is None: |
| 98 | self.version = seq_info |
| 99 | seq_info = None |
| 100 | if self.version and not self.version.startswith('v'): |
| 101 | self.prefix = self.version |
| 102 | self.version = None |
| 103 | if seq_info: |
| 104 | if mat_seq: |
| 105 | self.seq = int(mat_seq.group(1)) |
| 106 | self.count = int(mat_seq.group(2)) |
| 107 | else: |
| 108 | self.seq = 1 |
| 109 | self.count = 1 |
| 110 | |
| 111 | |
| 112 | class Review: |
| 113 | """Represents a single review email collected in Patchwork |
| 114 | |
| 115 | Patches can attract multiple reviews. Each consists of an author/date and |
| 116 | a variable number of 'snippets', which are groups of quoted and unquoted |
| 117 | text. |
| 118 | """ |
| 119 | def __init__(self, meta, snippets): |
| 120 | """Create new Review object |
| 121 | |
| 122 | Args: |
| 123 | meta (str): Text containing review author and date |
| 124 | snippets (list): List of snippets in th review, each a list of text |
| 125 | lines |
| 126 | """ |
| 127 | self.meta = ' : '.join([line for line in meta.splitlines() if line]) |
| 128 | self.snippets = snippets |
| 129 | |
| 130 | |
Simon Glass | 2c266d8 | 2025-04-29 07:22:13 -0600 | [diff] [blame] | 131 | class Patchwork: |
| 132 | """Class to handle communication with patchwork |
| 133 | """ |
| 134 | def __init__(self, url, show_progress=True): |
| 135 | """Set up a new patchwork handler |
| 136 | |
| 137 | Args: |
| 138 | url (str): URL of patchwork server, e.g. |
| 139 | 'https://patchwork.ozlabs.org' |
| 140 | """ |
| 141 | self.url = url |
| 142 | self.proj_id = None |
| 143 | self.link_name = None |
| 144 | self._show_progress = show_progress |
| 145 | self.semaphore = asyncio.Semaphore(MAX_CONCURRENT) |
| 146 | self.request_count = 0 |
| 147 | |
| 148 | async def _request(self, client, subpath): |
| 149 | """Call the patchwork API and return the result as JSON |
| 150 | |
| 151 | Args: |
| 152 | client (aiohttp.ClientSession): Session to use |
| 153 | subpath (str): URL subpath to use |
| 154 | |
| 155 | Returns: |
| 156 | dict: Json result |
| 157 | |
| 158 | Raises: |
| 159 | ValueError: the URL could not be read |
| 160 | """ |
| 161 | # print('subpath', subpath) |
| 162 | self.request_count += 1 |
| 163 | |
| 164 | full_url = f'{self.url}/api/1.2/{subpath}' |
| 165 | async with self.semaphore: |
| 166 | # print('full_url', full_url) |
| 167 | for i in range(RETRIES + 1): |
| 168 | try: |
| 169 | async with client.get(full_url) as response: |
| 170 | if response.status != 200: |
| 171 | raise ValueError( |
| 172 | f"Could not read URL '{full_url}'") |
| 173 | result = await response.json() |
| 174 | # print('- done', full_url) |
| 175 | return result |
| 176 | break |
| 177 | except aiohttp.client_exceptions.ServerDisconnectedError: |
| 178 | if i == RETRIES: |
| 179 | raise |