Fix: Eliminate uses of requests library (#7633)#7745
Fix: Eliminate uses of requests library (#7633)#7745achave11-ucsc wants to merge 21 commits intodevelopfrom
Conversation
feb9b32 to
b9bc81d
Compare
dadf91d to
aa2bfcf
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #7745 +/- ##
===========================================
+ Coverage 85.03% 85.08% +0.04%
===========================================
Files 162 163 +1
Lines 23306 23359 +53
===========================================
+ Hits 19819 19874 +55
+ Misses 3487 3485 -2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
eafd283 to
032ef7c
Compare
e5f9720 to
e0a85ba
Compare
5a24366 to
905b31f
Compare
nadove-ucsc
left a comment
There was a problem hiding this comment.
The commit "Eliminate uses of requests library in test/app_test_case.py (#7633)" touches many files besides the one references in the file. I think those changes were meant to go in other commits.
Many overly long lines. Some should probably be fixed in a separate commit since they predate your changes.
$ git diff develop | longlines
src/azul/service/drs_controller.py:261 def _dss_get_file(self, file_uuid, replica, **kwargs) -> urllib3.BaseHTTPResponse:
test/app_test_case.py:144 return self._http_client.urlopen('GET', str(self.base_url.set(path='/health/basic')),
test/health_check_test_case.py:91 response = self._http_client.urlopen('GET', str(self.base_url.set(path='/health/basic')),
test/health_check_test_case.py:98 response = self._http_client.urlopen('GET', str(self.base_url.set(path=('health', path))),
test/service/test_drs.py:199 def _mock_responses(self, helper: Urllib3Mock, redirects, file_uuid, file_version=None):
test/service/test_drs.py:202 self._dss_response(helper, file_uuid, file_version, 'aws', initial=True, _301=False)
test/service/test_drs.py:203 self._dss_response(helper, file_uuid, file_version, 'gcp', initial=True, _301=False)
test/service/test_drs.py:206 self._dss_response(helper, file_uuid, file_version, 'aws', initial=True, _301=True)
test/service/test_drs.py:207 self._dss_response(helper, file_uuid, file_version, 'gcp', initial=True, _301=True)
test/service/test_drs.py:211 self._dss_response(helper, file_uuid, file_version, 'aws', initial=False, _301=True)
test/service/test_drs.py:212 self._dss_response(helper, file_uuid, file_version, 'gcp', initial=False, _301=True)
test/service/test_drs.py:213 self._dss_response(helper, file_uuid, file_version, 'aws', initial=False, _301=False)
test/service/test_drs.py:214 self._dss_response(helper, file_uuid, file_version, 'gcp', initial=False, _301=False)
test/service/test_request_validation.py:61 def assertResponseStatus(self, url: furl, status: int) -> urllib3.BaseHTTPResponse:
test/service/test_response.py:1041 response = self._http_client.urlopen('GET', str(self.base_url.set(path=('index', entity_type),
test/service/test_response.py:3709 response = self._http_client.urlopen('GET', str(self.base_url.set(path='/index/catalogs')))
| return StatusRetryHttpClient(client) | ||
|
|
||
|
|
||
| def raise_on_status(response: urllib3.BaseHTTPResponse) -> None: |
There was a problem hiding this comment.
Is there a reason to deviate from the name used by the requests library?
| def raise_on_status(response: urllib3.BaseHTTPResponse) -> None: | |
| def raise_for_status(response: urllib3.BaseHTTPResponse) -> None: |
There was a problem hiding this comment.
Yes, raise_on_status (https://urllib3.readthedocs.io/en/stable/reference/urllib3.util.html) is more apt to urllib3, than introducing a different name (albeit familiar).
|
|
||
| def raise_on_status(response: urllib3.BaseHTTPResponse) -> None: | ||
| if not 200 <= response.status <= 399: | ||
| msg = f"{response.reason} for url: {response.url}" |
| 'retry-after': | ||
| response.headers['retry-after'] |
| } | ||
| url = self.dss_file_url(file_uuid) | ||
| return requests.api.get(str(url), params=dss_params, allow_redirects=False) | ||
| return self._http_client.request('GET', str(url), |
| no_retries: Retry = Retry(status=0, | ||
| status_forcelist={500}, | ||
| raise_on_status=False) |
There was a problem hiding this comment.
Consolidate with the identical definition in TestAppLogging, either in a superclass or as a standalone global in the http module.
| url = str(config.lambda_endpoint(lambda_name).set(path='/health/basic', | ||
| args={'catalog': self.catalog})) | ||
| helper.add('GET', | ||
| url, |
There was a problem hiding this comment.
| url = str(config.lambda_endpoint(lambda_name).set(path='/health/basic', | |
| args={'catalog': self.catalog})) | |
| helper.add('GET', | |
| url, | |
| url = config.lambda_endpoint(lambda_name).set(path='/health/basic', | |
| args={'catalog': self.catalog}) | |
| helper.add('GET', | |
| str(url), |
| with self.assertLogs(app.log, level=log_level) as app_log: | ||
| with self.assertLogs(azul.log, level=log_level) as azul_log: | ||
| response = requests.get(f'http://{host}:{port}{path}') | ||
| response = self._http_client.urlopen('GET', f'http://{host}:{port}{path}', |
|
|
||
|
|
||
| def _normalize_url(url: str) -> str: | ||
| f = furl(url).copy() |
There was a problem hiding this comment.
| f = furl(url).copy() | |
| f = furl(url) |
There was a problem hiding this comment.
Only .copy returns a mutable instance of furl. Otherwise mypy complains, unless a new URL is instantiated. The .copy pattern seems to already be in employed elsewhere in the codebase, hence the usage here.
| urllib3_mock, | ||
|
|
There was a problem hiding this comment.
| urllib3_mock, | |
| urllib3_mock, | |
| body: bytes | str = b'', | ||
| json_body: dict | None = None, | ||
| reason: str | None = None, | ||
| ) -> None: | ||
| if json_body is not None: | ||
| body = json.dumps(json_body).encode() | ||
| headers = {'Content-Type': 'application/json', **(headers or {})} | ||
| elif isinstance(body, str): | ||
| body = body.encode() | ||
| response = urllib3.HTTPResponse( | ||
| body=body, | ||
| headers=headers or {}, |
There was a problem hiding this comment.
| body: bytes | str = b'', | |
| json_body: dict | None = None, | |
| reason: str | None = None, | |
| ) -> None: | |
| if json_body is not None: | |
| body = json.dumps(json_body).encode() | |
| headers = {'Content-Type': 'application/json', **(headers or {})} | |
| elif isinstance(body, str): | |
| body = body.encode() | |
| response = urllib3.HTTPResponse( | |
| body=body, | |
| headers=headers or {}, | |
| body: bytes | str | JSON = b'', | |
| reason: str | None = None, | |
| ) -> None: | |
| if headers is None: | |
| headers = {} | |
| if isinstance(body, dict): | |
| body = json.dumps(body) | |
| headers['Content-Type'] = 'application/json' | |
| if isinstance(body, str): | |
| body = body.encode() | |
| response = urllib3.HTTPResponse( | |
| body=body, | |
| headers=headers, |
|
Assigning @dsotirho-ucsc for review since @nadove-ucsc is focused on other high priority work. |
dsotirho-ucsc
left a comment
There was a problem hiding this comment.
This should be a partial PR.
Regarding the changes to request_flooder.py:
- The script manually logs lines such as
Making GET request...andGOT 200 response..., so now that the HTTP client is also logging these there is duplicate output. - The
--log-headersoption is now redundant since the these are always being included in the output now. - (For the purpose of this script) the (
… with/without a request/response body) lines are unnecessary and ideally would be omitted if possible.
I think 1 and 2 should be fixed in this PR. I suspect we'll just have to live with 3.
old output
❯ python scripts/request_flooder.py --method GET --url https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary --rate 5 --per 30
2026-04-15 17:13:28,232 INFO MainThread __main__: Starting requests at a rate of 5 requests per 30 seconds for 300 seconds
2026-04-15 17:13:34,238 INFO ThreadPoolExecutor-0_0 __main__: Making GET request to 'https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary'
2026-04-15 17:13:35,326 INFO ThreadPoolExecutor-0_0 __main__: Got 200 response after 1.088s from GET to https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary
2026-04-15 17:13:40,243 INFO ThreadPoolExecutor-0_0 __main__: Making GET request to 'https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary'
2026-04-15 17:13:41,627 INFO ThreadPoolExecutor-0_0 __main__: Got 200 response after 1.383s from GET to https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary
2026-04-15 17:13:46,247 INFO ThreadPoolExecutor-0_0 __main__: Making GET request to 'https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary'
2026-04-15 17:13:47,246 INFO ThreadPoolExecutor-0_0 __main__: Got 200 response after 0.998s from GET to https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary
new output
❯ python scripts/request_flooder.py --method GET --url https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary --rate 5 --per 30
2026-04-15 17:15:10,458 INFO MainThread __main__: Starting requests at a rate of 5 requests per 30 seconds for 300 seconds
2026-04-15 17:15:16,464 INFO ThreadPoolExecutor-0_0 __main__: Making GET request to 'https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary'
2026-04-15 17:15:16,464 INFO ThreadPoolExecutor-0_0 __main__: Making GET request to 'https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary'
2026-04-15 17:15:16,464 INFO ThreadPoolExecutor-0_0 __main__: … without a request body
2026-04-15 17:15:16,475 INFO ThreadPoolExecutor-0_0 __main__: … without request headers
2026-04-15 17:15:17,693 INFO ThreadPoolExecutor-0_0 __main__: Got 200 response after 1.228s from GET to https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary
2026-04-15 17:15:17,693 INFO ThreadPoolExecutor-0_0 __main__: … with response headers HTTPHeaderDict({'Content-Type': 'application/json', 'Content-Length': '7808', 'Connection': 'keep-alive', 'Date': 'Thu, 16 Apr 2026 00:15:17 GMT', 'X-Amzn-Trace-Id': 'Root=1-69e02a14-1df05f260381a0f0103add9e;Parent=06e7b1b4225fd9bc;Sampled=0;Lineage=1:18704a40:0', 'x-amzn-RequestId': 'd1f3e699-5606-475f-9c3f-a7b0b90fbd18', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-XSS-Protection': '1; mode=block', 'Access-Control-Allow-Origin': '*', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', 'Access-Control-Allow-Headers': 'Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key', 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "default-src 'self';img-src 'self' data:;script-src 'self';style-src 'self';frame-ancestors 'none';form-action 'self'", 'x-amz-apigw-id': 'b4uDUGeaoAMEtUw=', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'X-Cache': 'Miss from cloudfront', 'Via': '1.1 3289feb7922c3bed2dd498f7353add3e.cloudfront.net (CloudFront)', 'X-Amz-Cf-Pop': 'IAD55-P5', 'X-Amz-Cf-Id': 'qihUI0blBmucscBWMmYXElCIRreehJAFeh-CjSjG_wvx9yH4DXnYxg=='})
2026-04-15 17:15:17,693 INFO ThreadPoolExecutor-0_0 __main__: … with a response body of length 7808 starting in b'{"projectCount":111,"specimenCount":1279,"speciesCount":2,"fileCount":5411,"totalFileSize":13444490554440.0,"donorCount":796,"labCount":241,"organTypes":["blood","brain","skin of body","kidney","lung","large intestine","wall of lateral ventricle","pancreas","gonad","hematopoietic system","ovary","central nervous system","endometrium","skeletal muscle organ","small intestine","heart","tumor","hindlimb","muscle","lymph node","embryo","eye","placenta","decidua","tonsil","umbilical cord","colon","digestive system","thymus","adipose tissue","liver","retina",null,"Molar tooth","adrenal gland","arterial blood vessel","blastocyst","muscle organ","nose","testis","whole embryos","bone marrow","calcareous tooth","cerebral cortex","immune system","stem cell","tail","uterus","abdomen","immune organ","mouse kidney","oral cavity","skin","trachea"],"fileTypeSummaries":[{"count":4655,"totalSize":9988470899450.0,"matrixCellCount":0.0,"format":"fastq.gz"},{"count":195,"totalSize":224837175327.0,"matrixCellCount":0.0,"format"...'
2026-04-15 17:15:17,693 INFO ThreadPoolExecutor-0_0 __main__: Got 200 response after 1.230s from GET to https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary
2026-04-15 17:15:22,467 INFO ThreadPoolExecutor-0_0 __main__: Making GET request to 'https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary'
2026-04-15 17:15:22,467 INFO ThreadPoolExecutor-0_0 __main__: Making GET request to 'https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary'
2026-04-15 17:15:22,467 INFO ThreadPoolExecutor-0_0 __main__: … without a request body
2026-04-15 17:15:22,468 INFO ThreadPoolExecutor-0_0 __main__: … without request headers
2026-04-15 17:15:23,248 INFO ThreadPoolExecutor-0_0 __main__: Got 200 response after 0.781s from GET to https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary
2026-04-15 17:15:23,248 INFO ThreadPoolExecutor-0_0 __main__: … with response headers HTTPHeaderDict({'Content-Type': 'application/json', 'Content-Length': '7808', 'Connection': 'keep-alive', 'Date': 'Thu, 16 Apr 2026 00:15:23 GMT', 'X-Amzn-Trace-Id': 'Root=1-69e02a1a-3b325fb232f7db1328e31abe;Parent=02f86c560bc72374;Sampled=0;Lineage=1:18704a40:0', 'x-amzn-RequestId': 'ef191851-bb3c-4ada-afd4-dc262c90f599', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-XSS-Protection': '1; mode=block', 'Access-Control-Allow-Origin': '*', 'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload', 'Access-Control-Allow-Headers': 'Authorization,Content-Type,X-Amz-Date,X-Amz-Security-Token,X-Api-Key', 'X-Frame-Options': 'DENY', 'Content-Security-Policy': "default-src 'self';img-src 'self' data:;script-src 'self';style-src 'self';frame-ancestors 'none';form-action 'self'", 'x-amz-apigw-id': 'b4uENG3PoAMEo5Q=', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'X-Cache': 'Miss from cloudfront', 'Via': '1.1 3289feb7922c3bed2dd498f7353add3e.cloudfront.net (CloudFront)', 'X-Amz-Cf-Pop': 'IAD55-P5', 'X-Amz-Cf-Id': 'l5oYqA1JjQjO8Vby_-MX0co9T8G6LlkNp1hptvUxz0Y1kxrL34xL8g=='})
2026-04-15 17:15:23,249 INFO ThreadPoolExecutor-0_0 __main__: … with a response body of length 7808 starting in b'{"projectCount":111,"specimenCount":1279,"speciesCount":2,"fileCount":5411,"totalFileSize":13444490554440.0,"donorCount":796,"labCount":241,"organTypes":["blood","brain","skin of body","kidney","lung","large intestine","wall of lateral ventricle","pancreas","gonad","hematopoietic system","ovary","central nervous system","endometrium","skeletal muscle organ","small intestine","heart","tumor","hindlimb","muscle","lymph node","embryo","eye","placenta","decidua","tonsil","umbilical cord","colon","digestive system","thymus","adipose tissue","liver","retina",null,"Molar tooth","adrenal gland","arterial blood vessel","blastocyst","muscle organ","nose","testis","whole embryos","bone marrow","calcareous tooth","cerebral cortex","immune system","stem cell","tail","uterus","abdomen","immune organ","mouse kidney","oral cavity","skin","trachea"],"fileTypeSummaries":[{"count":4655,"totalSize":9988470899450.0,"matrixCellCount":0.0,"format":"fastq.gz"},{"count":195,"totalSize":224837175327.0,"matrixCellCount":0.0,"format"...'
2026-04-15 17:15:23,249 INFO ThreadPoolExecutor-0_0 __main__: Got 200 response after 0.782s from GET to https://service.daniel.dev.singlecell.gi.ucsc.edu/index/summary
| if isinstance(body, str): | ||
| body = body.encode() | ||
| assert isinstance(body, (bytes, str)), type(body) |
There was a problem hiding this comment.
body would never be str in the assert, since right above that it is converted to bytes
|
|
||
| def filter_body(organ: str) -> JSON: | ||
| return {'filters': json.dumps({'organ': {'is': [organ]}})} | ||
| return {"filters": json.dumps({'organ': {'is': [organ]}})} |
There was a problem hiding this comment.
| return {"filters": json.dumps({'organ': {'is': [organ]}})} | |
| return {'filters': json.dumps({'organ': {'is': [organ]}})} |
| url.set(path='/') | ||
| response = requests.get(str(url)) | ||
| self.assertEqual(response.status_code, 200) | ||
| response = self._http_client.urlopen('GET', str(url)) |
There was a problem hiding this comment.
| response = self._http_client.urlopen('GET', str(url)) | |
| response = self._http_client.urlopen(GET, str(url)) |
integration_test.py has variables for the method types.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…/dss/__init__.py (#7633) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…adata/helpers/schema_validation.py (#7633)
Linked issues: #7633
Checklist
Author
developissues/<GitHub handle of author>/<issue#>-<slug>1 when the issue title describes a problem, the corresponding PR
title is
Fix:followed by the issue titleAuthor (partiality)
ptag to titles of partial commitspartialor completely resolves all linked issuespartiallabelAuthor (reindex)
rtag to commit title or the changes introduced by this PR will not require reindexing of any deploymentreindex:devor the changes introduced by it will not require reindexing ofdevreindex:anvildevor the changes introduced by it will not require reindexing ofanvildevreindex:anvilprodor the changes introduced by it will not require reindexing ofanvilprodreindex:prodor the changes introduced by it will not require reindexing ofprodreindex:partialand its description documents the specific reindexing procedure fordev,anvildev,anvilprodandprodor requires a full reindex or carries none of the labelsreindex:dev,reindex:anvildev,reindex:anvilprodandreindex:prodAuthor (API changes)
APIor this PR does not modify a REST APIa(A) tag to commit title for backwards (in)compatible changes or this PR does not modify a REST APIapp.pyor this PR does not modify a REST APIAuthor (upgrading deployments)
make docker_images.jsonand committed the resulting changes or this PR does not modifyazul_docker_images, or any other variables referenced in the definition of that variableutag to commit title or this PR does not require upgrading deploymentsupgradeor does not require upgrading deploymentsdeploy:sharedor does not modifydocker_images.json, and does not require deploying thesharedcomponent for any other reasondeploy:gitlabor does not require deploying thegitlabcomponentdeploy:runneror does not require deploying therunnerimageAuthor (hotfixes)
Ftag to main commit title or this PR does not include permanent fix for a temporary hotfixanvilprodandprod) have temporary hotfixes for any of the issues linked to this PRAuthor (before every review)
develop, squashed fixups from prior reviewsmake requirements_updateor this PR does not modifyDockerfile,environment,requirements*.txt,common.mk,Makefileorenvironment.bootRtag to commit title or this PR does not modifyrequirements*.txtreqsor does not modifyrequirements*.txtmake integration_testpasses in personal deployment or this PR does not modify functionality that could affect the IT outcomePeer reviewer (after approval)
Note that after requesting changes, the PR must be assigned to only the author.
System administrator (after approval)
demoorno demono demono sandboxN reviewslabel is accurateOperator
reindex:…labels andrcommit title tagno demodevelopOperator (deploy
.sharedand.gitlabcomponents)_select dev.shared && CI_COMMIT_REF_NAME=develop make -C terraform/shared apply_keep_unusedor this PR is not labeleddeploy:shared_select dev.gitlab && CI_COMMIT_REF_NAME=develop make -C terraform/gitlab applyor this PR is not labeleddeploy:gitlab_select anvildev.shared && CI_COMMIT_REF_NAME=develop make -C terraform/shared apply_keep_unusedor this PR is not labeleddeploy:shared_select anvildev.gitlab && CI_COMMIT_REF_NAME=develop make -C terraform/gitlab applyor this PR is not labeleddeploy:gitlabdeploy:gitlabdeploy:gitlabSystem administrator (post-deploy of
.gitlabcomponent)dev.gitlabare complete or this PR is not labeleddeploy:gitlabanvildev.gitlabare complete or this PR is not labeleddeploy:gitlabOperator (deploy runner image)
_select dev.gitlab && make -C terraform/gitlab/runneror this PR is not labeleddeploy:runner_select anvildev.gitlab && make -C terraform/gitlab/runneror this PR is not labeleddeploy:runnerOperator (sandbox build)
sandboxlabel or PR is labeledno sandboxdevor PR is labeledno sandboxanvildevor PR is labeledno sandboxsandboxdeployment or PR is labeledno sandboxanvilboxdeployment or PR is labeledno sandboxsandboxdeployment or PR is labeledno sandboxanvilboxdeployment or PR is labeledno sandboxsandboxor this PR does not remove catalogs or otherwise causes unreferenced indices insandboxanvilboxor this PR does not remove catalogs or otherwise causes unreferenced indices inanvilboxsandboxor this PR is not labeledreindex:devanvilboxor this PR is not labeledreindex:anvildevsandboxor this PR is not labeledreindex:devanvilboxor this PR is not labeledreindex:anvildevOperator (merge the branch)
pif the PR is also labeledpartialOperator (main build)
devanvildevdevdevanvildevanvildev_select dev.shared && make -C terraform/shared applyor this PR is not labeleddeploy:shared_select anvildev.shared && make -C terraform/shared applyor this PR is not labeleddeploy:shareddevanvildevOperator (reindex)
devor this PR is neither labeledreindex:partialnorreindex:devanvildevor this PR is neither labeledreindex:partialnorreindex:anvildevdevor this PR is neither labeledreindex:partialnorreindex:devanvildevor this PR is neither labeledreindex:partialnorreindex:anvildevdevor this PR is neither labeledreindex:partialnorreindex:devanvildevor this PR is neither labeledreindex:partialnorreindex:anvildevdevor this PR does not require reindexingdevanvildevor this PR does not require reindexinganvildevdevor this PR does not require reindexingdevanvildevor this PR does not require reindexinganvildevdevor this PR does not require reindexingdevanvildevor this PR does not require reindexinganvildevdevor this PR does not require reindexingdevdevor this PR does not require reindexingdevdeploy_browserjob in the GitLab pipeline for this PR indevor this PR does not require reindexingdevanvildevor this PR does not require reindexinganvildevdeploy_browserjob in the GitLab pipeline for this PR inanvildevor this PR does not require reindexinganvildevOperator (mirroring)
devor this PR does not require mirroringdevanvildevor this PR does not require mirroringanvildevdevor this PR does not require mirroringdevanvildevor this PR does not require mirroringanvildevdevor this PR does not require mirroringdevanvildevor this PR does not require mirroringanvildevOperator
deploy:shared,deploy:gitlab,deploy:runner,API,reindex:partial,reindex:anvilprodandreindex:prodlabels to the next promotion PRs or this PR carries none of these labelsdeploy:shared,deploy:gitlab,deploy:runner,API,reindex:partial,reindex:anvilprodandreindex:prodlabels, from the description of this PR to that of the next promotion PRs or this PR carries none of these labelsShorthand for review comments
Lline is too longWline wrapping is wrongQbad quotesFother formatting problem