F-Strings (Formatted String Literals)¶
An f-string interpolates expressions into a string. Each replacement field is
{expr[=][!conversion][:format_spec]}: the expression is evaluated, an optional = self-documents
it, an optional !r/!s/!a conversion is applied, and an optional format spec after : controls
padding, sign, precision and presentation. Sharpy follows Python 3.12 (PEP 701) semantics.
def main() -> None:
name: str = "Alice"
age: int = 30
print(f"My name is {name} and I'm {age} years old")
# Expressions in f-strings
print(f"Result: {age * 2}")
# Format specifiers
pi: float = 3.14159
print(f"Pi: {pi:.2f}")
One Format Engine¶
Every hole is rendered by a single Python-format engine, Sharpy.PyFormat.Apply, which str.format,
the format() builtin and every f-string / t-string hole share. There is no separate emitter-side
translator, so the format spec behaves exactly as it does in Python (the same engine, the same
rounding, the same padding).
| Hole shape | Renders as | Example → output |
|---|---|---|
{v} (plain) |
format(v, ""): str(v), or the type's own rendering of "" when it owns its spec (IFormattable) |
f"{None}" → None |
{v!r} |
repr(v) |
f"{'ab'!r}" → 'ab' |
{v!s} |
str(v) |
f"{None!s}" → None |
{v!a} |
ascii(v) |
f"{'café'!a}" → 'caf\xe9' |
{v=} |
"v=" + repr(v) |
f"{age=}" → age=30 |
{v:spec} |
PyFormat.Apply(v, spec) |
f"{3.14159:.2f}" → 3.14 |
{v=:spec} |
"v=" + PyFormat.Apply(v, spec) |
f"{pi=:.2f}" → pi=3.14 |
None Renders None¶
A plain hole calls str(value), so a None value renders None — not the empty string. This holds
for a bare None, a T | None variable, an object holding null, and an int? that is None():
Format Spec Grammar¶
The format spec is [[fill]align][sign][z][#][0][width][grouping][.precision][type]. Each component
has the same meaning as in Python, and is applied by the one engine:
| Component | Python meaning | Sharpy | Example → output |
|---|---|---|---|
align <^>= |
left / center / right / pad-after-sign | same engine | f"{5:<4}" → 5 |
fill + align |
pad with a fill char | same engine | f"{5:*^7}" → ***5*** |
sign +/-/space |
force / default / leading-space sign | same engine | f"{5:+}" → +5 |
z |
coerce negative zero (PEP 682) | same engine | f"{-0.0:z.1f}" → 0.0 |
# |
alternate form | same engine | f"{5:#x}" → 0x5 |
0 + width |
zero-pad | same engine | f"{5:05}" → 00005 |
width |
minimum field width | same engine | f"{5:5}" → 5 |
grouping , / _ |
thousands separator | same engine | f"{1234567:,}" → 1,234,567 |
.precision |
digits / significant figures | same engine | f"{3.14159:.2f}" → 3.14 |
type d f e g x X o b c n % s |
presentation | same engine | f"{255:X}" → FF |
z reads the rendered text, as CPython does: any rendering whose digits are all zero drops its
minus sign, including the exponent and percent forms and a negative value that rounds to zero:
A bare width with no alignment ({5:5}) pads to the field width — a case the old emitter-side
translator dropped:
def main() -> None:
print(f"[{5:5}]")
print(f"[{5:<5}]")
print(f"[{1234567:,}]")
print(f"[{255:X}]")
print(f"[{3.14159:.2f}]")
Because the engine is value-kind aware, the same spec formats differently by type (this is Python's
behavior, not a Sharpy quirk): {x:4} right-aligns an int, {s:4} left-aligns a str, and a bool
under a non-empty spec formats as its integer value:
def main() -> None:
x: int = 5
s: str = "ab"
b: bool = True
print(f"[{x:4}]")
print(f"[{s:4}]")
print(f"[{b:6}]")
The width, the precision and a str.format field index accept any Unicode decimal digit (category
Nd), as in Python — including a non-BMP digit such as MATHEMATICAL BOLD DIGIT THREE, which is one
digit although it is two UTF-16 code units:
def main() -> None:
print(f"[{5:٣}]")
print(f"[{3.14159:.٢f}]")
print(f"[{65:𝟑d}]")
print("{٠}|{١}".format("a", "b"))
Every other part of the spec is read in UTF-16 code units (Axiom 1): a non-BMP fill or type
code does not parse, so format("ab", "😀>5") is refused as Invalid format specifier where Python
pads with the emoji — the format-spec-non-bmp-code-units deviation in docs/deviations.yaml.
The Alternate Form and = Padding Are One Rule Each¶
# means the same thing on every float presentation type (e E f F g G n % and no type): the
result always has a decimal point, and g/G/n/no-type keep their trailing zeros. On an integer
presentation (b o x X) it adds the radix prefix. = alignment pads after the sign and the
radix prefix, whatever the fill character — 0, an explicit fill, or the default space:
def main() -> None:
print(f"[{42:#.0f}]")
print(f"[{3.5:#g}]")
print(f"[{42:#.0%}]")
print(f"[{-255:*=#10x}]")
print(f"[{255:=#10x}]")
print(f"[{255:0=#10x}]")
The Sign Applies to Every Numeric Presentation Type¶
A sign (+, -, or space) is applied to every numeric presentation type, including % and the
radix types — the sign is one rule, not a per-type special case:
The one exception is c (character): a sign with c is refused — statically as SPY0609 on a
literal spec, and at runtime with the same wording through a dynamic spec:
def main() -> None:
print(f"[{65:+c}]") # SPY0609: Sign not allowed with integer format specifier 'c'
The alternate form is refused with c the same way — c has no radix prefix to add:
def main() -> None:
print(f"[{65:#c}]") # SPY0609: Alternate form (#) not allowed with integer format specifier 'c'
String Operands¶
A string operand takes only fill/align, width and precision. The 0 flag zero-fills a string but
leaves it at the string default alignment <, so it fills on the right (Python's rule since 3.10 —
this is not =-style synthesis, which would fill on the left):
A sign, the alternate form #, and = alignment are all refused on a string, in CPython's order
(sign → z → # → =) and with CPython's wording — statically as SPY0609 on a literal spec:
def main() -> None:
s: str = "ab"
print(f"[{s:=5}]") # SPY0609: '=' alignment not allowed in string format specifier
The same refusals fire at runtime (as a ValueError) for a dynamic spec. A literal spec is
refused statically on every route that carries one — see Invalid Format Specs.
Nested Replacement Fields¶
A format spec is itself a mini f-string: a {...} inside the spec is a nested replacement field —
an ordinary expression the AST owns, evaluated at runtime. This makes dynamic width and precision
work (the previous implementation dropped the nested field and never type-checked it):
def main() -> None:
name: str = "Sam"
width: int = 10
print(f"[{name:>{width}}]")
precision: int = 3
pi: float = 3.14159
print(f"[{pi:.{precision}f}]")
The same runtime engine splits str.format/format_map replacement fields, so "{:{}}".format(1234, ">8")
also resolves its nested spec field. The recursion limit is per route, matching CPython: a
str.format spec may nest one level (a field inside a field's spec raises
ValueError: Max string recursion exceeded), while an f-string spec may nest one level deeper.
Evaluation Order¶
Holes — and the nested fields inside their specs — evaluate strictly left to right, in source order,
exactly once each. In f"{a} {b}", a is evaluated before b; in f"{x:{w}}", x is evaluated
before w. A hole with a side effect therefore observes the same order Python does:
xs.pop(0) runs first (returning 1 and shortening the list), so len(...) sees the two remaining
elements — 1 2, not 1 3.
Invalid Format Specs¶
A static spec (no nested fields) whose type is known is validated at compile time and refused by
name as SPY0609, carrying CPython's exact ValueError/TypeError wording:
def main() -> None:
x: str = "a"
print(f"[{x:d}]") # SPY0609: Unknown format code 'd' for object of type 'str'
A non-empty spec on a None literal is refused the same way
(unsupported format string passed to NoneType.__format__).
The static check is one rule shared by every route that carries a literal spec: an f-string or
t-string hole, the format() builtin (bare, builtins.format, format_spec= by keyword, or
pipe-forward s |> format("=5"), which binds as format(s, "=5")), and each field of a
str.format call whose template is a string literal. The same spec is refused with
the same SPY0609 wording wherever it is written:
def main() -> None:
s: str = "ab"
print(f"{s:=5}") # SPY0609: '=' alignment not allowed in string format specifier
print(t"{s:=5}") # SPY0609 (same wording)
print(format(s, "=5")) # SPY0609 (same wording)
print(s |> format("=5")) # SPY0609 (same wording)
print("{:=5}".format(s)) # SPY0609 (same wording)
A str.format field with a nested spec ("{:{}}"), an attribute or index field, a template held
in a variable, and format_map are not visible to the static check; they are validated by the
engine at runtime. So are a format method-group value called later (f = format; f(v, "=5"),
1997), a pipe into a qualified callee (s |> builtins.format("=5"), #1999), and a _ placeholder¶
partial (format(_, "=5"), whose placeholder is typed object?). str.format takes positional fields only — a keyword argument is refused with
SPY0234 and a steer to an f-string or format_map.
The fields of a literal str.format template are checked in order, because CPython raises the
first field's error. A field that can never be bound is refused as SPY0613 — a positional
index past the arguments (an automatic {} field counts by the index it claims), or a keyword field,
since str.format binds positional arguments only — where CPython raises IndexError/KeyError
at runtime; the check then stops:
def main() -> None:
s: str = "ab"
print("{5}{0:=5}".format(s)) # SPY0613: format field '{5}' cannot be bound: only 1 positional argument
print("{name}".format(s)) # SPY0613: ... str.format takes positional arguments only; use an f-string or format_map({...})
The check also stops — silently, leaving every later field to the runtime — at the first field it
cannot decide: a nested spec ({:{}}), an attribute or index field ({0.real}, {0[1]}), an
operand whose type is not known statically, and, under a non-empty spec, a type that owns its spec
(__format__/IFormattable), whose own formatting may raise first. So
"{:{}}{:=5}".format(1, ">3", "ab") compiles and raises CPython's ValueError for the second field
at runtime, while "{0:=5}{5}".format("ab") is SPY0609 on the first field. A template held in a
variable, and format_map, bind at runtime and raise CPython's KeyError (the key's repr):
def main() -> None:
t: str = "{name}"
try:
print(t.format("ab"))
except KeyError as e:
print("KeyError:", e)
try:
print("{x}".format_map({"a": 1}))
except KeyError as e:
print("KeyError:", e)
An f-string or t-string, by contrast, checks each hole on its own: every hole is its own expression, so two bad literal specs in one f-string are two SPY0609s where CPython reports only the first. That is a difference in how many diagnostics are shown, not in which programs run.
Refusal Precedence¶
A spec that breaks more than one rule is refused with the rule CPython checks first. One validator
in the runtime (Sharpy.PyFormatSpec) holds the rules; the static check calls it, so the order and
the wording are the same at compile time and at runtime:
| Operand | Order (first refusal wins) |
|---|---|
| every kind | the spec does not parse (Invalid format specifier) → ,/_ with a presentation type that takes no grouping → unknown presentation type for the operand |
int, bool (integer presentations b c d o x X n) |
precision → z → sign with c → # with c |
str |
sign → z → # → = alignment |
complex |
zero padding → = alignment |
def main() -> None:
print(f"[{65:+.3c}]") # SPY0609: Precision not allowed in integer format specifier
print(f"[{65:<+z#010c}]") # SPY0609: Negative zero coercion (z) not allowed in integer format specifier
A decimal operand (System.Decimal) is formatted by the float rules and named decimal in every
message (Unknown format code 'd' for object of type 'decimal'); python's decimal.Decimal has its
own __format__ grammar (format(Decimal("1.5"), "e") is 1.5e+0 where Sharpy prints
1.500000e+00), recorded as the decimal-format-grammar deviation.
Types Without __format__¶
A non-empty spec on a value whose type has no __format__ — a list, dict, set, frozenset,
tuple, bytes, Optional, or a class that does not implement System.IFormattable — is
CPython's TypeError, raised before the spec is even parsed. A literal spec is refused statically
as SPY0609 on every route; a dynamic one raises the TypeError at runtime:
class Point:
x: int = 0
def main() -> None:
xs: list[int] = [1, 2]
print(f"[{xs:>8}]") # SPY0609: unsupported format string passed to list.__format__
print(f"[{Point():>8}]") # SPY0609: unsupported format string passed to Point.__format__
The empty spec is never refused (it is str(value), or the type's own __format__("")), and a conversion turns the operand into a
str before the spec applies, so !r/!s/!a followed by a spec is the way to pad a
collection's text:
def main() -> None:
xs: list[int] = [1, 2]
print(f"[{xs!r:>8}]")
spec: str = ">8"
try:
print(f"[{xs:{spec}}]")
except TypeError as e:
print("TypeError:", e)
Optional has no Python twin; it is refused like the collections — unwrap it first. An enum member formats as its str, as in Python (Enum.__format__ is
str.__format__(str(self), spec)), so f"{Color.RED:>5}" pads the member's text and
f"{Color.RED:d}" is SPY0609 Unknown format code 'd' for object of type 'str'. A string-backed
enum is Python's StrEnum, a str subclass: it pads its value (f"{Mood.HAPPY:>3}" is h),
and a refused spec names the enum, Unknown format code 'd' for object of type 'Mood'. That holds
both statically and at runtime.
A type that owns its spec. A class or struct that declares
def __format__(self, spec: str) -> str owns its spec, as in Python: the compiler synthesizes
System.IFormattable for it (announced by SPY1001; see Dunder Methods),
every route — the f-string hole, format(), str.format, a t-string — hands the body the spec
verbatim, the empty spec included, and no spec is refused statically:
class Money:
cents: int
def __init__(self, cents: int):
self.cents = cents
def __format__(self, spec: str) -> str:
if spec == "short":
return "$" + str(self.cents // 100)
return "$" + str(self.cents // 100) + "." + str(self.cents % 100)
def main() -> None:
m = Money(1250)
print(f"[{m:short}]")
print(format(m, "short"))
print("{:full}".format(m))
print(f"[{m}]")
A subclass overrides it with @override def __format__, and super().__format__(spec) reaches the
base's. __format__ beside __str__ is two different members (f"{m}" asks __format__(""),
str(m) asks __str__).
The CLR spelling. System.IFormattable is also implementable directly (declaring it beside
__format__ is SPY0522 — two spellings of one member): a class that implements it
receives any non-empty spec verbatim in to_string(fmt, provider) on every route, and no spec is
refused statically — the spec is the type's own business:
from System import IFormattable, IFormatProvider
class Money(IFormattable):
cents: int
def __init__(self, cents: int):
self.cents = cents
def to_string(self, fmt: str, provider: IFormatProvider) -> str:
if fmt == "short":
return "$" + str(self.cents // 100)
return "$" + str(self.cents // 100) + "." + str(self.cents % 100)
def main() -> None:
m: Money = Money(1250)
print(f"[{m:short}]")
print(f"[{m:full}]")
print(format(m, "short"))
print(f"[{m}]")
An empty spec — a plain hole f"{m}", "{}".format(m), format(m, "") — reaches the type too,
with fmt the empty string, as CPython calls __format__("").
This is also how a .NET type's own format strings reach it — Sharpy-only, with no Python twin:
DateTime is IFormattable, so its .NET format string applies:
from System import DateTime
def main() -> None:
d: DateTime = DateTime(2020, 1, 2)
print(f"{d:yyyy}")
print(format(d, "yyyy-MM-dd"))
One difference from CPython follows from deciding the kind from the static type. A hole typed
as a base class that holds a subclass implementing IFormattable is refused statically, where
CPython would dispatch to the subclass at runtime; write the spec dynamically (f"{v:{spec}}") or
type the hole as the subclass. A hole typed object, an interface or a union is never refused
statically — its runtime value decides, and a plain hole of such a type renders through the engine
(format(v, "")), so an IFormattable value behind it is asked for "" as well.
A dynamic spec (one with a nested field, or a hole whose type is not statically known) is
validated by the engine at runtime and raises the same ValueError/TypeError Python would. The
program below compiles, then raises ValueError: Unknown format code 'q' for object of type 'int'
when run:
Implicit String Conversion¶
Non-string expressions in a plain hole are converted to strings via str() (which calls __str__
or .ToString()), matching both Python's f-string behavior and C#'s string interpolation — except
a value whose type owns its spec (IFormattable), which renders its own "" spec, as above:
Interpolation Hole Type¶
Each interpolation hole is typed as an object slot, so any expression is accepted — including a
conditional with unrelated branch types (both branches are admitted to object):
Because the hole (and any nested spec field) is a real expression the AST owns, a variable used only inside a spec is a genuine use — an unused-variable warning is not raised for it.
F-String Nesting Rules¶
Sharpy supports nested f-strings, matching Python 3.12+ (PEP 701): a replacement field holds an
ordinary expression, so a string literal inside it may reuse the enclosing quote character and may
carry any string prefix — f, t, r, b, d, dr or df — exactly as at statement level
(one prefix dispatch serves both). The lexer uses a mode stack to track nested interpolation
contexts.
def main() -> None:
name: str = "Alice"
print(f"Hello, {f"dear {name}"}!")
print(f"{"quoted"} {f'{name!r}'}")
print(f"{r'\d'} {b'ab'} {d'plain'}")
print(f"{t'{name}'!r}")
Hello, dear Alice!
quoted 'Alice'
\d b'ab' plain
Template(strings=('', ''), interpolations=(Interpolation('Alice', 'name', None, ''),))
rf/fr and uppercase prefixes are not supported anywhere (#2045).
Literal braces: double a brace to include a literal { or }:
Dictionary literals in f-strings: a { right after the hole's { would read as the {{
escape, so pad it with a space ({ {'key': value}['key'] }, which format preserves), wrap the dict
literal in parentheses, or bind it to a variable first:
Hole Delimiter Rules¶
Inside a hole ({…}), a : at brace depth 1 starts a format spec. When the : is inside
parentheses or brackets, it is part of the expression — not a spec delimiter. This lets a
parenthesized expression that contains : appear in a hole:
def f() -> str:
return "hello"
def main() -> None:
# Walrus — parenthesized to avoid format-spec parse
print(f"{(s := f())} {s}")
# Lambda — parenthesized call
print(f"{(lambda: 42)()}")
# Slice — parenthesized indexing
xs: list[int] = [1, 2, 3, 4]
print(f"{xs[0:2]}")
Without parentheses, {s := f()} would parse : as the format-spec start. The same applies to
{lambda: 42} (parsed as lambda with format spec 42}) and bare slices. A bare {x:=10} (no
parentheses) is intentionally a format specifier — it formats x with the spec =10, matching
Python; only the parenthesized form is a walrus.
Newlines and comments. Inside a hole, whitespace is insignificant and may span lines — in
single- and triple-quoted f-strings alike (PEP 701). A # comment runs to the end of its line; a
} inside the comment does not close the hole, and a # inside a string literal is not a comment.
Whitespace, newlines and comments may also follow a !r/!s/!a conversion, and a backslash
continues a hole's line:
def main() -> None:
x: int = 5
items: list[str] = ["a", "b"]
print(f"total: {
x * 2 # doubled; a } here closes nothing
}")
print(f"{', '.join(
items
)}")
print(f"{x!r # the conversion, then the spec
:>4}|")
Because a hole may span lines, a ) or ] that closes nothing inside a hole is refused where it
stands, as in Python (f-string: unmatched ')'), rather than letting the unclosed hole swallow the
lines that follow:
Implementation
- Every hole lowers to Builtins.Str/Repr/Ascii(v) or Sharpy.PyFormat.Apply(v, spec) — one Python-format engine, no emitter-side translator.
- Holes and nested spec fields are generated through the ordered-operand helper (source order).
- Nested f-strings require the lexer mode stack; each hole pushes StorePosition.FStringHole with slot object.