#!/usr/bin/env python3
"""Codemod for the T1.12 admin boundary migration (rustfs/backlog#2788).
Rewrites admin handler files under ``rustfs/src/admin/handlers/**`` from the
``s3s`` request/response types (``S3Request
`` / ``S3Response<(StatusCode,
Body)>`` / ``crate::admin::router::Operation``) to the owned boundary defined in
``rustfs/src/admin/operation.rs`` (``AdminRequest`` / ``AdminResponse`` /
``crate::admin::operation::Operation``).
Usage::
python3 scripts/dev_t112_admin_codemod.py [--check] [--credential-methods] [--rustfmt] ...
``--check`` prints what would change (per-file line deltas and the TODO list)
without writing anything. ``--credential-methods`` additionally rewrites the
obvious ``cred.access_key`` / ``cred.secret_key`` field accesses on bindings
taken from ``req.credentials()`` into the ``RequestCredentials`` accessor
methods; by default those sites only get a TODO. ``--rustfmt`` runs
``rustfmt --edition 2024`` on every file that was written (syntax only, no
compilation).
The script is idempotent: running it again on an already migrated file makes no
further change, and TODO comments are never duplicated.
Everything the script cannot rewrite mechanically is left in place with a
``// TODO(T1.12-codemod): `` line inserted directly above it. Every
TODO (new or pre-existing) is listed on stdout as ``file:line: reason``.
Rewrite rules (see the per-phase functions below for the exact shapes):
1. imports: ``Operation`` leaves ``crate::admin::router`` imports and the owned
names are imported from ``crate::admin::operation``; ``Body``/``S3Request``/
``S3Response`` leave ``s3s``/``storage_api::s3`` imports; ``Body`` is imported
from ``rustfs_s3_types`` when still used; ``s3s::header::*`` becomes
``http::header::*``; ``LegacyAdminAdapter`` statics are removed and routes
register the handler directly.
2. signatures: ``S3Request`` -> ``AdminRequest``,
``S3Response<(StatusCode, Body)>`` -> ``AdminResponse``; ``mut`` on by-value
``AdminRequest`` parameters is kept only when the body needs it.
3. request fields: ``req.uri``/``req.headers``/``req.method``/``req.credentials``
become accessor calls; ``req.extensions`` lookups become ``remote_addr()``,
``req_info()``, ``req_info_mut()``, ``install_req_info()``, ``client_info()``,
``request_context()``; ``req.region``/``req.service`` and the remaining
``req.extensions`` uses get a TODO.
4. helpers: ``authorize_admin_request(&req, ..)`` ->
``authorize_admin_envelope(req.envelope(), ..)``; ``*_from_req(&req)`` ->
``*_from_envelope(req.envelope())``; ``*_from_extensions(&req.extensions`` ->
``*_from_envelope(req.envelope()``; ``KmsAdminAudit::from_request(..)`` ->
``from_envelope(req.envelope(), ..)``; ``CallerIdentity::resolve(&req)`` ->
``resolve_envelope(req.envelope())``; ``AccountAudit*::from_request(&req)`` ->
``from_envelope(req.envelope())``. Import lines follow the renames.
5. request body: ``req.input`` / ``std::mem::take(&mut req.input)`` ->
``req.take_body()``; assignments to ``req.input`` get a TODO.
6. responses: ``S3Response::new((A, B))`` -> ``AdminResponse::new(A, B)``;
``S3Response::with_headers((A, B), H)`` -> ``AdminResponse::with_headers(A, B, H)``;
other shapes and response field access get a TODO.
7. tests: ``S3Request { .. }`` literals become
``crate::admin::operation::test_support::TestRequest::new(method, uri)
.headers(..).extensions(..).body(..)[.credentials(a, b, None)].build()``.
Literals that feed the s3s-facing ``S3Router`` directly (``router.call`` /
``check_access``) and literals in production code are left alone, together
with the function that owns them, under a TODO.
8. string literals in source-inspecting tests follow the helper renames; any
remaining mention of a pre-migration symbol gets a TODO.
"""
from __future__ import annotations
import argparse
import difflib
import re
import subprocess
import sys
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path
TODO_PREFIX = "// TODO(T1.12-codemod): "
SENT_OPEN = "\x01"
SENT_CLOSE = "\x02"
MAX_WIDTH = 130
# --------------------------------------------------------------------------- reasons
R_REGION = "request region/service is now Option<&str> via region()/service(); adjust the caller"
R_EXT = "request extensions are private on AdminRequest; use an accessor (remote_addr/req_info/client_info/request_context) or envelope()"
R_URI_ASSIGN = "assigning the request URI is not supported on AdminRequest"
R_HDR_MUT = "mutating request headers needs envelope_mut() on AdminRequest"
R_METHOD_ASSIGN = "assigning the request method is not supported on AdminRequest"
R_CRED_ASSIGN = "assigning request credentials is not supported on AdminRequest; build the request with credentials instead"
R_CRED_FIELD = "RequestCredentials exposes access_key()/secret_key()/session_token() methods (secret_key() returns &str) instead of fields"
R_BORROW = "AdminRequest accessors borrow (uri()/headers()/credentials()); clone or change the callee if it needs ownership"
R_INPUT_ASSIGN = "assigning the request body is not supported on AdminRequest; build the request with the body instead"
R_RESP_CTOR = "S3Response::new/with_headers with a non-(StatusCode, Body) output has no AdminResponse equivalent"
R_RESP_TYPE = "S3Response with a non-(StatusCode, Body) output has no AdminResponse equivalent"
R_REQ_TYPE = "S3Request with a non-Body input has no AdminRequest equivalent"
R_RESP_FIELD = "AdminResponse fields are private; use with_headers()/into_parts() instead of field access"
R_OUTPUT = "S3Response output tuple is gone; destructure AdminResponse::into_parts() instead"
R_LITERAL = "S3Request literal has an unsupported field value; build it with test_support::TestRequest manually"
R_LITERAL_REGION = "TestRequest region/service value is not None; verify the builder accepts it"
R_ROUTER = "router-level test drives the s3s-facing S3Router; keep s3s request types until the router boundary moves"
R_PROD_LITERAL = "production code builds an s3s S3Request by hand; construct AdminRequest::new(RequestEnvelope, Body) or keep the s3s type until the generic auth path moves"
R_STRING = "string literal references a pre-migration symbol; update the source-inspecting test"
# --------------------------------------------------------------------------- scanner
_TOKEN_RE = re.compile(r"//[^\n]*|/\*|(? None:
self.text = text
n = len(text)
mask = bytearray(b"\x01") * n # 1 = code
strings: list[tuple[int, int]] = []
pairs: dict[int, int] = {}
stack: list[int] = []
pos = 0
while True:
m = _TOKEN_RE.search(text, pos)
if m is None:
break
tok = m.group(0)
i = m.start()
if tok.startswith("//"):
j = m.end()
mask[i:j] = bytes(j - i)
pos = j
elif tok == "/*":
j = _skip_block_comment(text, i)
mask[i:j] = bytes(j - i)
pos = j
elif tok.endswith('"'):
if "r" in tok:
hashes = tok.count("#")
close = '"' + "#" * hashes
k = text.find(close, m.end())
j = n if k < 0 else k + len(close)
else:
j = _skip_plain_string(text, m.end() - 1)
mask[i:j] = bytes(j - i)
strings.append((i, j))
pos = j
elif tok.startswith("'"):
j = m.end()
mask[i:j] = bytes(j - i)
pos = j
else:
if tok in "([{":
stack.append(i)
elif stack:
o = stack.pop()
pairs[o] = i
pairs[i] = o
pos = m.end()
self.mask = mask
self.pairs = pairs
self.strings = strings
def is_code(self, i: int) -> bool:
return 0 <= i < len(self.mask) and self.mask[i] == 1
def match(self, i: int) -> int | None:
return self.pairs.get(i)
def next_code(self, pattern: re.Pattern[str], start: int) -> re.Match[str] | None:
pos = start
while True:
m = pattern.search(self.text, pos)
if m is None:
return None
if self.is_code(m.start()):
return m
pos = m.end() if m.end() > m.start() else m.start() + 1
def _skip_block_comment(text: str, i: int) -> int:
depth = 1
j = i + 2
n = len(text)
while j < n and depth:
if text.startswith("/*", j):
depth += 1
j += 2
elif text.startswith("*/", j):
depth -= 1
j += 2
else:
j += 1
return j
def _skip_plain_string(text: str, i: int) -> int:
"""``i`` points at the opening quote; returns the index after the closing quote."""
j = i + 1
n = len(text)
while j < n:
c = text[j]
if c == "\\":
j += 2
continue
if c == '"':
return j + 1
j += 1
return n
# --------------------------------------------------------------------------- text utils
def line_start(text: str, pos: int) -> int:
return text.rfind("\n", 0, pos) + 1
def line_end(text: str, pos: int) -> int:
j = text.find("\n", pos)
return len(text) if j < 0 else j
def indent_of(text: str, pos: int) -> str:
ls = line_start(text, pos)
m = re.match(r"[ \t]*", text[ls:])
return m.group(0) if m else ""
def add_sentinel(text: str, pos: int, reason: str) -> str:
"""Attach a TODO sentinel to the end of the line containing ``pos``."""
le = line_end(text, pos)
token = f"{SENT_OPEN}{reason}{SENT_CLOSE}"
if token in text[line_start(text, pos):le]:
return text
return text[:le] + token + text[le:]
def materialize_todos(text: str) -> str:
"""Turn end-of-line sentinels into TODO comment lines above their line."""
if SENT_OPEN not in text:
return text
out_lines: list[str] = []
for line in text.split("\n"):
reasons: list[str] = []
for m in re.finditer(f"{SENT_OPEN}(.*?){SENT_CLOSE}", line):
if m.group(1) not in reasons:
reasons.append(m.group(1))
if not reasons:
out_lines.append(line)
continue
clean = re.sub(f"{SENT_OPEN}.*?{SENT_CLOSE}", "", line)
indent = re.match(r"[ \t]*", clean).group(0)
existing: list[str] = []
k = len(out_lines) - 1
while k >= 0 and out_lines[k].strip().startswith(TODO_PREFIX.strip()):
existing.append(out_lines[k].strip())
k -= 1
for reason in reasons:
todo = f"{TODO_PREFIX}{reason}"
if todo not in existing:
out_lines.append(f"{indent}{todo}")
out_lines.append(clean)
return "\n".join(out_lines)
def split_top_level(sc: Scanner, start: int, end: int) -> list[tuple[int, int]]:
"""Split ``text[start:end]`` at top-level commas; returns (s, e) spans."""
spans: list[tuple[int, int]] = []
depth = 0
i = start
item_start = start
text = sc.text
while i < end:
if not sc.is_code(i):
i += 1
continue
c = text[i]
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
elif c == "," and depth == 0:
spans.append((item_start, i))
item_start = i + 1
i += 1
if text[item_start:end].strip():
spans.append((item_start, end))
return spans
def strip_span(text: str, span: tuple[int, int]) -> str:
return text[span[0]:span[1]].strip()
# --------------------------------------------------------------------------- function spans
@dataclass
class FnSpan:
name: str
ordinal: int # index among same-named functions, in file order
sig_start: int
params: tuple[int, int] # inside the parens
body: tuple[int, int] # inside the braces
@property
def key(self) -> tuple[str, int]:
return (self.name, self.ordinal)
_FN_RE = re.compile(r"\bfn\s+(\w+)\s*(<|\()")
def iter_fns(sc: Scanner) -> list[FnSpan]:
text = sc.text
out: list[FnSpan] = []
counts: Counter[str] = Counter()
pos = 0
while True:
m = sc.next_code(_FN_RE, pos)
if m is None:
break
pos = m.end()
i = m.end() - 1
if text[i] == "<":
depth = 0
while i < len(text):
if sc.is_code(i):
if text[i] == "<":
depth += 1
elif text[i] == ">":
depth -= 1
if depth == 0:
i += 1
break
i += 1
while i < len(text) and text[i] != "(":
i += 1
if i >= len(text):
break
p_open = i
p_close = sc.match(p_open)
if p_close is None:
continue
j = p_close + 1
body_open = None
while j < len(text):
if sc.is_code(j):
if text[j] == ";":
break
if text[j] == "{":
body_open = j
break
j += 1
if body_open is None:
continue
body_close = sc.match(body_open)
if body_close is None:
continue
name = m.group(1)
out.append(FnSpan(name, counts[name], m.start(), (p_open + 1, p_close), (body_open + 1, body_close)))
counts[name] += 1
return out
def enclosing_fn(fns: list[FnSpan], pos: int) -> FnSpan | None:
inside = [f for f in fns if f.body[0] <= pos < f.body[1]]
if not inside:
return None
return min(inside, key=lambda f: f.body[1] - f.body[0])
# --------------------------------------------------------------------------- context
@dataclass
class Ctx:
path: Path
credential_methods: bool
frozen: dict[tuple[str, int], str] = field(default_factory=dict) # fn key -> reason
adapter_map: dict[str, str] = field(default_factory=dict)
def frozen_spans(self, sc: Scanner, fns: list[FnSpan] | None = None) -> list[tuple[int, int]]:
if not self.frozen:
return []
fns = fns if fns is not None else iter_fns(sc)
return [(f.sig_start, f.body[1]) for f in fns if f.key in self.frozen]
def in_spans(pos: int, spans: list[tuple[int, int]]) -> bool:
return any(s <= pos < e for s, e in spans)
_ROUTER_CALL_RE = re.compile(r"\.check_access\(|\brouter\s*\.\s*call\(")
_LITERAL_RE = re.compile(r"(? int:
"""Offset where test-only code starts (whole file for ``tests.rs`` modules)."""
if path.name == "tests.rs" or "/tests/" in path.as_posix():
return 0
m = re.search(r"^#\[cfg\(test\)\]\s*\n(?:pub(?:\([^)]*\))?\s+)?mod\s+\w+", text, re.M)
return m.start() if m else len(text)
def is_test_code(text: str, pos: int, fns: list[FnSpan], test_start: int) -> bool:
if pos >= test_start:
return True
f = enclosing_fn(fns, pos)
if f is None:
return False
above = text[max(0, line_start(text, f.sig_start) - 200):f.sig_start]
return _TEST_ATTR_RE.search(above) is not None
def detect_frozen_fns(text: str, path: Path) -> dict[tuple[str, int], str]:
"""Functions that must keep the s3s request types, with the reason."""
sc = Scanner(text)
fns = iter_fns(sc)
frozen: dict[tuple[str, int], str] = {}
for f in fns:
if _ROUTER_CALL_RE.search(text[f.body[0]:f.body[1]]):
frozen[f.key] = R_ROUTER
builders = {m.group(1) for m in re.finditer(r"\brouter\s*\.\s*call\(\s*(\w+)\(", text)}
for f in fns:
if f.name in builders:
frozen[f.key] = R_ROUTER
test_start = test_region_start(text, path)
pos = 0
while True:
m = sc.next_code(_LITERAL_RE, pos)
if m is None:
break
pos = m.end()
if is_test_code(text, m.start(), fns, test_start):
continue
f = enclosing_fn(fns, m.start())
if f is not None and f.key not in frozen:
frozen[f.key] = R_PROD_LITERAL
return frozen
# --------------------------------------------------------------------------- request identifiers
_REQ_PARAM_RE = re.compile(r"(?]*>|AdminRequest)\b")
def request_scopes(sc: Scanner, fns: list[FnSpan]) -> list[tuple[str, int, int]]:
"""Identifiers typed as the admin request, with the span they are valid in."""
scopes: list[tuple[str, int, int]] = []
for f in fns:
params = sc.text[f.params[0]:f.params[1]]
for m in _REQ_PARAM_RE.finditer(params):
if m.group(1) != "self":
scopes.append((m.group(1), f.sig_start, f.body[1]))
return scopes
def is_request_ident(name: str, pos: int, scopes: list[tuple[str, int, int]]) -> bool:
if name == "req":
return True
return any(n == name and s <= pos < e for n, s, e in scopes)
# --------------------------------------------------------------------------- rewrite helpers
def rewrite(text: str, pattern: re.Pattern[str], fn, *, only_code: bool = True, ctx: Ctx | None = None) -> str:
"""Apply ``fn(match, sc, scopes) -> (replacement|None, reason|None)`` back to front."""
sc = Scanner(text)
fns = iter_fns(sc)
frozen = ctx.frozen_spans(sc, fns) if ctx else []
scopes = request_scopes(sc, fns)
out = text
for m in reversed(list(pattern.finditer(text))):
if only_code and not sc.is_code(m.start()):
continue
if frozen and in_spans(m.start(), frozen):
continue
res = fn(m, sc, scopes)
if res is None:
continue
repl, reason = res
if repl is not None:
out = out[:m.start()] + repl + out[m.end():]
if reason:
out = add_sentinel(out, m.start(), reason)
return materialize_todos(out)
def simple_sub(text: str, pattern: str, repl: str, *, ctx: Ctx | None = None, only_code: bool = True) -> str:
return rewrite(text, re.compile(pattern), lambda m, sc, scopes: (m.expand(repl), None), ctx=ctx, only_code=only_code)
def req_sub(text: str, pattern: str, repl: str | None, reason: str | None, ctx: Ctx, *, group: int = 1) -> str:
"""Substitute ``pattern`` whose ``group`` captures the request identifier."""
def fn(m, sc, scopes):
if not is_request_ident(m.group(group), m.start(group), scopes):
return None
return (m.expand(repl) if repl is not None else None, reason)
return rewrite(text, re.compile(pattern), fn, ctx=ctx)
# --------------------------------------------------------------------------- phase: request literals (tests)
_STR = r"\"(?:[^\"\\]|\\.)*\""
def _unwrap_uri(expr: str) -> str:
m = re.fullmatch(rf"(?:http::)?Uri::from_static\(\s*({_STR})\s*\)", expr, re.S)
if m:
return m.group(1)
m = re.fullmatch(rf"({_STR})\s*\.parse\(\)\s*\.(?:unwrap|expect)\([^()]*\)", expr, re.S)
if m:
return m.group(1)
# A non-literal `.parse()` lost the `uri: Uri` field annotation that drove
# type inference; make the target type explicit.
m = re.fullmatch(r"(.*?)\.parse\(\)(\s*\.(?:unwrap|expect)\([^()]*\))?", expr, re.S)
if m and "::<" not in expr:
return f"{m.group(1)}.parse::(){m.group(2) or ''}"
return expr
def _unwrap_into(expr: str) -> str:
"""Strip the owned-string conversions a literal does not need: the owned
``TestRequest::credentials`` takes ``&str``."""
while True:
m = re.fullmatch(rf"({_STR})\s*\.(?:into|to_string|to_owned)\(\)", expr, re.S)
if m is None:
m = re.fullmatch(rf"String::from\(\s*({_STR})\s*\)", expr, re.S)
if m is None:
return expr
expr = m.group(1)
def _unwrap_secret(expr: str) -> str:
m = re.fullmatch(r"(?:s3s::auth::|s3_auth::)?SecretKey::from\((.*)\)", expr, re.S)
return _unwrap_into(m.group(1).strip()) if m else _unwrap_into(expr)
_CRED_LIT_RE = re.compile(r"\s*Some\(\s*(?:s3s::auth::|s3_auth::)?Credentials\s*\{")
def _parse_credentials(sc: Scanner, span: tuple[int, int]) -> tuple[str, str] | None:
text = sc.text
m = _CRED_LIT_RE.match(text, span[0])
if not m or m.end() > span[1]:
return None
open_brace = m.end() - 1
close = sc.match(open_brace)
if close is None:
return None
fields: dict[str, str] = {}
for s, e in split_top_level(sc, open_brace + 1, close):
item = text[s:e].strip()
fm = re.match(r"(\w+)\s*:\s*(.*)$", item, re.S)
if fm:
fields[fm.group(1)] = fm.group(2).strip()
elif re.fullmatch(r"\w+", item):
fields[item] = item
if set(fields) != {"access_key", "secret_key"}:
return None
return (_unwrap_into(fields["access_key"]), _unwrap_secret(fields["secret_key"]))
def _reindent(expr: str, old_indent: str, new_indent: str) -> str:
lines = expr.split("\n")
if len(lines) == 1:
return expr
out = [lines[0]]
for ln in lines[1:]:
if ln.startswith(old_indent):
ln = new_indent + ln[len(old_indent):]
out.append(ln)
return "\n".join(out)
def convert_request_literals(text: str, ctx: Ctx) -> str:
pos = 0
while True:
sc = Scanner(text)
m = sc.next_code(_LITERAL_RE, pos)
if m is None:
break
open_brace = m.end() - 1
close_brace = sc.match(open_brace)
if close_brace is None:
pos = m.end()
continue
if in_spans(m.start(), ctx.frozen_spans(sc)):
pos = m.end() + 1
continue
def give_up() -> tuple[str, int]:
return materialize_todos(add_sentinel(text, m.start(), R_LITERAL)), close_brace + 1
fields: dict[str, tuple[int, int]] = {}
ok = True
for s, e in split_top_level(sc, open_brace + 1, close_brace):
item = text[s:e]
fm = re.match(r"\s*(\w+)\s*:\s*", item)
if fm:
fields[fm.group(1)] = (s + fm.end(), e)
elif re.fullmatch(r"\s*\w+\s*", item):
name = item.strip()
fields[name] = (s + item.index(name), s + item.index(name) + len(name))
else:
ok = False
needed = {"method", "uri", "headers", "extensions", "input"}
optional = {"credentials", "region", "service", "trailing_headers"}
if not ok or not needed <= set(fields) or set(fields) - needed - optional:
text, pos = give_up()
continue
creds = None
if "credentials" in fields and strip_span(text, fields["credentials"]) != "None":
creds = _parse_credentials(sc, fields["credentials"])
if creds is None:
text, pos = give_up()
continue
if "trailing_headers" in fields and strip_span(text, fields["trailing_headers"]) != "None":
text, pos = give_up()
continue
base_indent = indent_of(text, m.start())
chain_indent = base_indent + " "
def value(name: str) -> str:
s, e = fields[name]
return _reindent(text[s:e].strip(), indent_of(text, s), chain_indent)
parts = [f"crate::admin::operation::test_support::TestRequest::new({value('method')}, {_unwrap_uri(value('uri'))})"]
parts.append(f".headers({value('headers')})")
parts.append(f".extensions({value('extensions')})")
parts.append(f".body({value('input')})")
if creds is not None:
parts.append(f".credentials({creds[0]}, {creds[1]}, None)")
reasons: list[str] = []
for opt in ("region", "service"):
if opt in fields and strip_span(text, fields[opt]) != "None":
parts.append(f".{opt}({value(opt)})")
reasons.append(R_LITERAL_REGION)
parts.append(".build()")
replacement = parts[0] + "".join(f"\n{chain_indent}{p}" for p in parts[1:])
text = text[:m.start()] + replacement + text[close_brace + 1:]
for reason in reasons:
text = add_sentinel(text, m.start(), reason)
text = materialize_todos(text)
pos = m.start() + len(replacement)
return text
# --------------------------------------------------------------------------- phase: types
def rename_types(text: str, ctx: Ctx) -> str:
text = simple_sub(text, r"(?", "AdminRequest", ctx=ctx)
text = simple_sub(
text,
r"(?",
"AdminResponse",
ctx=ctx,
)
text = rewrite(text, re.compile(r"(? str:
"""Frozen functions keep s3s types; spell them out so the s3s imports can go."""
if not ctx.frozen:
return text
sc = Scanner(text)
spans = ctx.frozen_spans(sc)
out = text
for m in reversed(list(re.finditer(r"(? str:
text = simple_sub(text, r"\b(\w+)_from_extensions\(\s*&(\w+)\.extensions\b", r"\1_from_envelope(\2.envelope()", ctx=ctx)
text = req_sub(
text,
r"\bKmsAdminAudit::from_request\(\s*&(\w+)\.extensions\s*,\s*&\1\.headers\s*,\s*",
r"KmsAdminAudit::from_envelope(\1.envelope(), ",
None,
ctx,
)
text = req_sub(text, r"\bCallerIdentity::resolve\(\s*&?(\w+)\s*\)", r"CallerIdentity::resolve_envelope(\1.envelope())", None, ctx)
text = req_sub(text, r"\b(AccountAudit\w*)::from_request\(\s*&?(\w+)\s*\)", r"\1::from_envelope(\2.envelope())", None, ctx, group=2)
rx = re.compile(r"\bauthorize_admin_request\((\s*)&?(\w+)(\s*),")
def auth_fn(m, sc, scopes):
if sc.is_code(m.start()) and not is_request_ident(m.group(2), m.start(2), scopes):
return None
return (f"authorize_admin_envelope({m.group(1)}{m.group(2)}.envelope(){m.group(3)},", None)
# Also inside string literals: source-inspecting tests grep their own file.
text = rewrite(text, rx, auth_fn, ctx=ctx, only_code=False)
text = req_sub(text, r"\b(object_store|app_context)_from_req\(\s*&?(\w+)\s*\)", r"\1_from_envelope(\2.envelope())", None, ctx, group=2)
return text
SEP = r"(\s*\.\s*)" # `req.field` or `req\n .field`
def rewrite_fields(text: str, ctx: Ctx) -> str:
rx = re.compile(
r"\b(\w+)\s*\.\s*extensions\s*\.\s*get::