bcbd1bdb22
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
600 lines
20 KiB
Python
600 lines
20 KiB
Python
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
|
|
|
import re
|
|
from functools import partial
|
|
|
|
from mirage.commands.builtin.utils.types import _ReadBytes, _WriteBytes
|
|
|
|
_SIMPLE_CMDS = frozenset("dDpPhHgGxNq")
|
|
|
|
|
|
def _apply_repl(m: "re.Match[str]", repl: str) -> str:
|
|
"""Expand a GNU sed replacement against a match.
|
|
|
|
`&` is the whole match, `\\1`..`\\9` are groups, `\\&` is a literal `&`,
|
|
`\\n`/`\\t` are newline/tab, and `\\X` is a literal X.
|
|
|
|
Args:
|
|
m (re.Match): The regex match for the current substitution.
|
|
repl (str): The sed replacement template.
|
|
"""
|
|
out: list[str] = []
|
|
i = 0
|
|
while i < len(repl):
|
|
ch = repl[i]
|
|
if ch == "\\" and i + 1 < len(repl):
|
|
nxt = repl[i + 1]
|
|
if nxt.isdigit():
|
|
grp = m.group(int(nxt))
|
|
out.append(grp if grp is not None else "")
|
|
elif nxt == "n":
|
|
out.append("\n")
|
|
elif nxt == "t":
|
|
out.append("\t")
|
|
else:
|
|
out.append(nxt)
|
|
i += 2
|
|
elif ch == "&":
|
|
out.append(m.group(0))
|
|
i += 1
|
|
else:
|
|
out.append(ch)
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
|
|
def _parse_address(addr: str) -> tuple[str, str] | None:
|
|
if not addr:
|
|
return None
|
|
if addr[0] == "/":
|
|
end = addr.index("/", 1)
|
|
return ("regex", addr[1:end])
|
|
if addr.isdigit():
|
|
return ("line", addr)
|
|
if addr == "$":
|
|
return ("last", "")
|
|
return None
|
|
|
|
|
|
def _consume_address(rest: str) -> tuple[tuple[str, str] | None, str]:
|
|
if not rest:
|
|
return None, rest
|
|
if rest[0] == "/":
|
|
end = rest.index("/", 1)
|
|
addr = ("regex", rest[1:end])
|
|
return addr, rest[end + 1:]
|
|
if rest[0].isdigit() or rest[0] == "$":
|
|
num = ""
|
|
while rest and (rest[0].isdigit() or rest[0] == "$"):
|
|
num += rest[0]
|
|
rest = rest[1:]
|
|
return _parse_address(num), rest
|
|
return None, rest
|
|
|
|
|
|
def _parse_one_command(rest: str) -> tuple[dict, str]:
|
|
addr_start = None
|
|
addr_end = None
|
|
|
|
addr_start, rest = _consume_address(rest)
|
|
if addr_start and rest.startswith(","):
|
|
addr_end, rest = _consume_address(rest[1:])
|
|
|
|
# Optional address negation: `addr!command` (whitespace allowed around `!`)
|
|
# applies the command to every line the address does NOT select.
|
|
negate = False
|
|
probe = rest.lstrip(" ")
|
|
if probe.startswith("!"):
|
|
negate = True
|
|
rest = probe[1:].lstrip(" ")
|
|
|
|
if not rest:
|
|
raise ValueError("sed: missing command")
|
|
|
|
ch = rest[0]
|
|
|
|
if ch == "{":
|
|
return {
|
|
"cmd": "{",
|
|
"addr_start": addr_start,
|
|
"addr_end": addr_end,
|
|
"negate": negate,
|
|
}, rest[1:]
|
|
if ch == "}":
|
|
return {"cmd": "}"}, rest[1:]
|
|
if ch == ":":
|
|
label = ""
|
|
rest = rest[1:]
|
|
while rest and rest[0] not in (";", "}"):
|
|
label += rest[0]
|
|
rest = rest[1:]
|
|
return {
|
|
"cmd": ":",
|
|
"label": label.strip(),
|
|
}, rest
|
|
if ch == "b":
|
|
label = ""
|
|
rest = rest[1:]
|
|
while rest and rest[0] not in (";", "}"):
|
|
label += rest[0]
|
|
rest = rest[1:]
|
|
return {
|
|
"cmd": "b",
|
|
"label": label.strip(),
|
|
"addr_start": addr_start,
|
|
"addr_end": addr_end,
|
|
"negate": negate,
|
|
}, rest
|
|
if ch == "t":
|
|
label = ""
|
|
rest = rest[1:]
|
|
while rest and rest[0] not in (";", "}"):
|
|
label += rest[0]
|
|
rest = rest[1:]
|
|
return {
|
|
"cmd": "t",
|
|
"label": label.strip(),
|
|
"addr_start": addr_start,
|
|
"addr_end": addr_end,
|
|
"negate": negate,
|
|
}, rest
|
|
if ch == "s":
|
|
delim = rest[1]
|
|
# Read pattern and replacement up to the next delimiter, then consume
|
|
# only the trailing flag characters; anything after is a separate
|
|
# command (a plain split would fold `s/a/b/;d` into the flags).
|
|
idx = 2
|
|
|
|
# A backslash escapes the next char (incl. the delimiter: s/a\/b/c/).
|
|
def _field() -> str:
|
|
nonlocal idx
|
|
out: list[str] = []
|
|
while idx < len(rest) and rest[idx] != delim:
|
|
if rest[idx] == "\\" and idx + 1 < len(rest):
|
|
out.append(rest[idx:idx + 2])
|
|
idx += 2
|
|
continue
|
|
out.append(rest[idx])
|
|
idx += 1
|
|
idx += 1
|
|
return "".join(out)
|
|
|
|
pattern = _field()
|
|
replacement = _field()
|
|
expr_flags = ""
|
|
while idx < len(rest) and rest[idx] in "0123456789gpiImMe":
|
|
expr_flags += rest[idx]
|
|
idx += 1
|
|
cm = re.search(r"\d+", expr_flags)
|
|
if cm and int(cm.group()) == 0:
|
|
raise ValueError(
|
|
"sed: number option to `s' command may not be zero")
|
|
return {
|
|
"cmd": "s",
|
|
"pattern": pattern,
|
|
"replacement": replacement,
|
|
"expr_flags": expr_flags,
|
|
"addr_start": addr_start,
|
|
"addr_end": addr_end,
|
|
"negate": negate,
|
|
}, rest[idx:]
|
|
if ch == "y":
|
|
# y/src/dst/ — transliterate src[i] -> dst[i]; the two sets must match
|
|
# in length. Read both fields up to the delimiter (no trailing flags).
|
|
delim = rest[1]
|
|
idx = 2
|
|
|
|
def _yfield() -> str:
|
|
nonlocal idx
|
|
start = idx
|
|
while idx < len(rest) and rest[idx] != delim:
|
|
idx += 1
|
|
value = rest[start:idx]
|
|
idx += 1
|
|
return value
|
|
|
|
pattern = _yfield()
|
|
replacement = _yfield()
|
|
if len(pattern) != len(replacement):
|
|
raise ValueError(
|
|
"sed: strings for `y` command are different lengths")
|
|
return {
|
|
"cmd": "y",
|
|
"pattern": pattern,
|
|
"replacement": replacement,
|
|
"addr_start": addr_start,
|
|
"addr_end": addr_end,
|
|
"negate": negate,
|
|
}, rest[idx:]
|
|
if ch in _SIMPLE_CMDS:
|
|
return {
|
|
"cmd": ch,
|
|
"addr_start": addr_start,
|
|
"addr_end": addr_end,
|
|
"negate": negate,
|
|
}, rest[1:]
|
|
if ch in ("a", "i", "c"):
|
|
# Text forms: `a\` <newline> text (classic multi-line form, where the
|
|
# backslash-newline is a continuation and not part of the text),
|
|
# `a\text`, and the one-line `a text`. Strip that leading prefix so the
|
|
# text itself does not start with a stray newline.
|
|
text = rest[1:]
|
|
if text.startswith("\\"):
|
|
text = text[1:]
|
|
if text.startswith("\n"):
|
|
text = text[1:]
|
|
elif text.startswith(" "):
|
|
text = text[1:]
|
|
end = len(text)
|
|
for j, c in enumerate(text):
|
|
if c == ";":
|
|
end = j
|
|
break
|
|
return {
|
|
"cmd": ch,
|
|
"text": text[:end],
|
|
"addr_start": addr_start,
|
|
"addr_end": addr_end,
|
|
"negate": negate,
|
|
}, text[end:]
|
|
|
|
raise ValueError(f"sed: unsupported command: {ch!r}")
|
|
|
|
|
|
def _parse_program(expr: str) -> list[dict]:
|
|
commands: list[dict] = []
|
|
rest = expr.strip()
|
|
while rest:
|
|
if rest[0] in (";", "\n"):
|
|
rest = rest[1:].lstrip()
|
|
continue
|
|
if rest[0] == " ":
|
|
rest = rest[1:]
|
|
continue
|
|
cmd, rest = _parse_one_command(rest)
|
|
commands.append(cmd)
|
|
rest = rest.lstrip()
|
|
return commands
|
|
|
|
|
|
def _bre_to_ere(pat: str) -> str:
|
|
"""Translate a POSIX Basic Regular Expression to Extended syntax.
|
|
|
|
GNU sed scripts are BRE by default and ERE only under -E/-r. In BRE the
|
|
bare metacharacters ``( ) { } + ? |`` are literal and their backslashed
|
|
forms are special; ERE is the reverse. ``^``/``$`` are anchors only at the
|
|
start/end (literal elsewhere) and a leading ``*`` is literal. The Python
|
|
``re`` engine is ERE-compatible, so feeding it the translated pattern
|
|
reproduces GNU BRE behavior.
|
|
"""
|
|
out: list[str] = []
|
|
i = 0
|
|
n = len(pat)
|
|
# True when the next char begins the regex or a subexpression (after \( or
|
|
# \|), where * is literal and ^ is an anchor.
|
|
at_start = True
|
|
while i < n:
|
|
ch = pat[i]
|
|
if ch == "[":
|
|
out.append("[")
|
|
j = i + 1
|
|
if j < n and pat[j] == "^":
|
|
out.append("^")
|
|
j += 1
|
|
if j < n and pat[j] == "]":
|
|
out.append("]")
|
|
j += 1
|
|
while j < n and pat[j] != "]":
|
|
out.append(pat[j])
|
|
j += 1
|
|
if j < n:
|
|
out.append("]")
|
|
j += 1
|
|
i = j
|
|
at_start = False
|
|
continue
|
|
if ch == "\\":
|
|
nx = pat[i + 1] if i + 1 < n else ""
|
|
if nx == "":
|
|
out.append("\\")
|
|
i += 1
|
|
continue
|
|
if nx in "(){}+?|":
|
|
out.append(nx)
|
|
at_start = nx in "(|"
|
|
i += 2
|
|
continue
|
|
out.append("\\" + nx)
|
|
at_start = False
|
|
i += 2
|
|
continue
|
|
if ch in "(){}+?|":
|
|
out.append("\\" + ch)
|
|
at_start = False
|
|
i += 1
|
|
continue
|
|
if ch == "*":
|
|
out.append("\\*" if at_start else "*")
|
|
at_start = False
|
|
i += 1
|
|
continue
|
|
if ch == "^":
|
|
out.append("^" if at_start else "\\^")
|
|
i += 1
|
|
continue
|
|
if ch == "$":
|
|
is_end = (i == n - 1 or (pat[i + 1] == "\\" and i + 2 < n
|
|
and pat[i + 2] in ")|"))
|
|
out.append("$" if is_end else "\\$")
|
|
at_start = False
|
|
i += 1
|
|
continue
|
|
out.append(ch)
|
|
at_start = False
|
|
i += 1
|
|
return "".join(out)
|
|
|
|
|
|
def _re_pattern(pat: str, extended: bool) -> str:
|
|
return pat if extended else _bre_to_ere(pat)
|
|
|
|
|
|
def _addr_matches(addr: tuple[str, str],
|
|
line: str,
|
|
lineno: int,
|
|
total: int,
|
|
extended: bool = False) -> bool:
|
|
kind, val = addr
|
|
if kind == "line":
|
|
return lineno == int(val)
|
|
if kind == "last":
|
|
return lineno == total
|
|
if kind == "regex":
|
|
return re.search(_re_pattern(val, extended), line) is not None
|
|
return False
|
|
|
|
|
|
def _split_content_lines(text: str) -> tuple[list[str], bool]:
|
|
"""Line contents WITHOUT trailing newlines (the pattern space excludes the
|
|
separator). The bool records whether the last line ended with a newline, so
|
|
output can preserve a missing final newline. Splits only on ``\\n``."""
|
|
if text == "":
|
|
return [], False
|
|
final_newline = text.endswith("\n")
|
|
body = text[:-1] if final_newline else text
|
|
return body.split("\n"), final_newline
|
|
|
|
|
|
def _execute_program(text: str,
|
|
commands: list[dict],
|
|
suppress: bool = False,
|
|
extended: bool = False) -> str:
|
|
lines, final_newline = _split_content_lines(text)
|
|
total = len(lines)
|
|
hold = ""
|
|
output: list[str] = []
|
|
label_map: dict[str, int] = {}
|
|
for idx, cmd in enumerate(commands):
|
|
if cmd["cmd"] == ":":
|
|
label_map[cmd["label"]] = idx
|
|
range_active: dict[int, bool] = {}
|
|
|
|
# Trailing newline for a pattern space whose last consumed line is `ln`
|
|
# (1-based): every line gets one except a last line that had none on input.
|
|
def tail_nl(ln: int) -> str:
|
|
return "\n" if (ln < total or final_newline) else ""
|
|
|
|
i = 0
|
|
while i < total:
|
|
pattern = lines[i]
|
|
i += 1
|
|
lineno = i
|
|
deferred: list[str] = []
|
|
|
|
pc = 0
|
|
delete = False
|
|
substituted = False
|
|
|
|
while pc < len(commands):
|
|
cmd = commands[pc]
|
|
c = cmd["cmd"]
|
|
|
|
if c == ":" or c == "}":
|
|
pc += 1
|
|
continue
|
|
|
|
addr_start = cmd.get("addr_start")
|
|
addr_end = cmd.get("addr_end")
|
|
|
|
matched = True
|
|
if addr_start is not None:
|
|
if addr_end is not None:
|
|
rid = id(cmd)
|
|
if not range_active.get(rid, False):
|
|
if _addr_matches(addr_start, pattern, lineno, total,
|
|
extended):
|
|
range_active[rid] = True
|
|
else:
|
|
matched = False
|
|
if range_active.get(rid, False):
|
|
if _addr_matches(addr_end, pattern, lineno, total,
|
|
extended):
|
|
range_active[rid] = False
|
|
else:
|
|
if not _addr_matches(addr_start, pattern, lineno, total,
|
|
extended):
|
|
matched = False
|
|
|
|
# addr!cmd inverts the selection (range state tracked normally).
|
|
if cmd.get("negate"):
|
|
matched = not matched
|
|
|
|
if c == "{":
|
|
if not matched:
|
|
depth = 1
|
|
pc += 1
|
|
while pc < len(commands) and depth > 0:
|
|
if commands[pc]["cmd"] == "{":
|
|
depth += 1
|
|
elif commands[pc]["cmd"] == "}":
|
|
depth -= 1
|
|
pc += 1
|
|
continue
|
|
pc += 1
|
|
continue
|
|
|
|
if not matched:
|
|
pc += 1
|
|
continue
|
|
|
|
if c == "s":
|
|
pat = cmd["pattern"]
|
|
repl = cmd["replacement"]
|
|
eflags = cmd["expr_flags"]
|
|
re_flags = re.IGNORECASE if "i" in eflags else 0
|
|
# `nth` is the 1-based occurrence the substitution starts at
|
|
# (GNU sed's numeric s///N flag, default 1). Without `g` only
|
|
# that occurrence is replaced; with `g` that one and every
|
|
# later one are. Count matches and decide per match so both
|
|
# `N` and `Ng` work.
|
|
digits = re.search(r"\d+", eflags)
|
|
nth = int(digits.group()) if digits else 1
|
|
global_ = "g" in eflags
|
|
counter = [0]
|
|
|
|
# Defaults bind the per-command values early (the closure is
|
|
# defined inside the command loop and used immediately).
|
|
def _repl(m: "re.Match[str]",
|
|
_repl_s: str = repl,
|
|
_nth: int = nth,
|
|
_global: bool = global_,
|
|
_counter: list = counter) -> str:
|
|
_counter[0] += 1
|
|
hit = (_counter[0] >= _nth
|
|
if _global else _counter[0] == _nth)
|
|
return _apply_repl(m, repl=_repl_s) if hit else m.group(0)
|
|
|
|
new_pattern = re.sub(_re_pattern(pat, extended),
|
|
_repl,
|
|
pattern,
|
|
flags=re_flags)
|
|
changed = new_pattern != pattern
|
|
if changed:
|
|
substituted = True
|
|
pattern = new_pattern
|
|
# s///p prints the pattern space when a substitution was made.
|
|
if changed and "p" in eflags:
|
|
output.append(pattern + tail_nl(lineno))
|
|
elif c == "d":
|
|
delete = True
|
|
break
|
|
elif c == "D":
|
|
nl = pattern.find("\n")
|
|
if nl >= 0:
|
|
pattern = pattern[nl + 1:]
|
|
pc = 0
|
|
continue
|
|
delete = True
|
|
break
|
|
elif c == "p":
|
|
output.append(pattern + tail_nl(lineno))
|
|
elif c == "P":
|
|
nl = pattern.find("\n")
|
|
output.append(pattern[:nl + 1] if nl >= 0 else pattern +
|
|
tail_nl(lineno))
|
|
elif c == "N":
|
|
if i < total:
|
|
pattern += "\n" + lines[i]
|
|
i += 1
|
|
lineno = i
|
|
else:
|
|
break
|
|
elif c == "h":
|
|
hold = pattern
|
|
elif c == "H":
|
|
# GNU appends newline + pattern unconditionally (empty hold ->
|
|
# leading newline).
|
|
hold = hold + "\n" + pattern
|
|
elif c == "g":
|
|
pattern = hold
|
|
elif c == "G":
|
|
# GNU appends newline + hold unconditionally (empty hold ->
|
|
# blank line).
|
|
pattern = pattern + "\n" + hold
|
|
elif c == "x":
|
|
pattern, hold = hold, pattern
|
|
elif c == "a":
|
|
deferred.append(cmd["text"] + "\n")
|
|
elif c == "i":
|
|
output.append(cmd["text"] + "\n")
|
|
elif c == "y":
|
|
# Transliterate pattern[i] -> replacement[i].
|
|
pattern = pattern.translate(
|
|
str.maketrans(cmd["pattern"], cmd["replacement"]))
|
|
elif c == "c":
|
|
# Change: delete the pattern space and emit the text. For a
|
|
# single address (or none) emit on each match; for a range emit
|
|
# once, when the range closes (or at EOF), matching GNU sed.
|
|
delete = True
|
|
is_range = addr_end is not None
|
|
range_open = range_active.get(id(cmd), False)
|
|
if (not is_range) or (not range_open) or (lineno == total):
|
|
output.append(cmd["text"] + "\n")
|
|
break
|
|
elif c == "q":
|
|
output.append(pattern + tail_nl(lineno))
|
|
return "".join(output)
|
|
elif c == "b":
|
|
label = cmd.get("label", "")
|
|
if label and label in label_map:
|
|
pc = label_map[label]
|
|
continue
|
|
break
|
|
elif c == "t":
|
|
if substituted:
|
|
substituted = False
|
|
label = cmd.get("label", "")
|
|
if label and label in label_map:
|
|
pc = label_map[label]
|
|
continue
|
|
break
|
|
|
|
pc += 1
|
|
|
|
if not delete:
|
|
if not suppress:
|
|
output.append(pattern + tail_nl(lineno))
|
|
output.extend(deferred)
|
|
|
|
return "".join(output)
|
|
|
|
|
|
def sed(
|
|
read_bytes: _ReadBytes,
|
|
write_bytes: _WriteBytes,
|
|
path: str,
|
|
pattern: str,
|
|
replacement: str,
|
|
flags: int = 0,
|
|
count: int = 0,
|
|
) -> None:
|
|
data = read_bytes(path).decode(errors="replace")
|
|
new_data = re.sub(pattern,
|
|
partial(_apply_repl, repl=replacement),
|
|
data,
|
|
flags=flags,
|
|
count=count)
|
|
write_bytes(path, new_data.encode())
|