strings and Characters¶
in python, a string is a sequence of code points, but these don't represent user facing characters,
which can be descried as extended grapheme clusters β the
characters a reader perceives β not code points. basedpython extends the str api to suite:
character_count is the number of grapheme clusters, and first / last read the string in whole
graphemes
the distinction is not cosmetic. a grapheme cluster can be several code points
β the US flag is two, a zwj emoji like the facepalm is five β so character_count
and len disagree:
flag = "πΊπΈ" # "\U0001F1FA\U0001F1F8"
flag.character_count # 1 β one visual character
len(flag) # 2 β two code points
facepalm = "π€¦πΌββοΈ" # face + skin tone + zwj + male sign + variation selector
facepalm.character_count # 1 β one visual character
len(facepalm) # 5 β five code points
none of the surface exists at python runtime β every access is a compile-time transformation into a plain python expression:
| basedpython | Python output | type |
|---|---|---|
s.character_count |
len(_by_graphemes(s)) |
int |
s.first |
(Character(_by_graphemes(s)[0]) if s else None) |
Character \| None |
s.last |
(Character(_by_graphemes(s)[-1]) if s else None) |
Character \| None |
s.characters |
[Character(c) for c in _by_graphemes(s)] |
Sequence[Character] |
s.character_at(i) |
Character(_by_graphemes(s)[i]) |
Character |
s.reversed |
"".join(_by_graphemes(s)[::-1]) |
str |
s.drop_first() |
"".join(_by_graphemes(s)[1:]) |
str |
s.drop_last() |
"".join(_by_graphemes(s)[:-1]) |
str |
s.prefix(n) |
_by_prefix(s, n) |
str |
s.suffix(n) |
_by_suffix(s, n) |
str |
s.unicode_scalars |
iter(s) |
Iterator[str] |
the Character-producing accessors (first / last / character_at /
characters) construct real Character instances β Character is a
concrete str subclass, not a type-only alias, and the transpiler emits a
runtime class Character(str) for it, so isinstance(x, Character) works
_by_graphemes is a small injected helper that splits a string into extended
grapheme clusters via the regex module's
\X. regex is the only widely available python engine that implements unicode
UAX #29 correctly (zwj emoji, regional-indicator flags, β¦), so it is a runtime
dependency of the grapheme surface: a program that uses character_count /
first / last / characters / character_at must have regex installed
(pip install regex). if it is missing, the helper raises a clear ImportError
rather than silently miscounting β len-style code-point splitting would give
5 for the facepalm above, not 1
regex is Matthew Barnett's work, licensed Apache-2.0 AND CNRI-Python β see
credits
the rewrites are type-directed: they fire only when the receiver is a string
(str, Character, LiteralString, a literal, or a str subclass).
character_count on a list, or on a user-defined attribute, passes through
untouched
the Character type¶
Character is a single extended grapheme cluster β a concrete subtype of str
from ty_extensions. it comes from the grapheme accessors (first, last,
character_at, characters) or from explicit construction; a raw s[0] is a
code point (str), not a Character:
from ty_extensions import Character
def first_grapheme(s: str) -> Character:
return s.character_at(0) # a Character
# `return s[0]` would be an error β s[0] is a `str` (code point)
a Character is exactly one grapheme cluster, which may span several code
points. ty knows this about it (though raw str operations still behave as they
do on any string):
| operation | type | why |
|---|---|---|
c.character_count |
Literal[1] |
always exactly one grapheme |
len(c) |
int |
a grapheme may be >1 code point |
c.first, c.last |
Character |
the only grapheme is c itself |
c[i], iter(c) |
str |
inherited str code-point ops |
c + c, c * n, c.upper() |
str |
results can be longer than 1 |
a string literal inhabits Character exactly when it is a single grapheme
cluster:
a: Character = "a" # ok
flag: Character = "πΊπΈ" # ok β one grapheme, two code points
b: Character = "ab" # error: two graphemes
c: Character = "" # error: empty
string literals keep their precise literal types β "hello"[0] is
Literal["h"], a subtype of Character. indexing a LiteralString keeps
returning LiteralString
an annotation materialises a Character¶
because Character is a concrete class, a Character-annotated assignment
constructs a real instance β the transpiler wraps the value in Character(...)
so the runtime class is Character, not a plain str:
x: Character = "a" # β x: Character = Character("a")
print(type(x)) # <class 'Character'>, not <class 'str'>
a bare "a" on its own is still an ordinary str β the coercion is driven by
the annotation. the wrap is applied
only when the annotation is exactly Character and the value is not already one:
x: str = "a", x: Character | None = "a", and x: Character = s.character_at(0)
(already a Character) are all left untouched, and a local class named
Character shadows the coercion
character_count, not count¶
python already owns str.count(sub) as the occurrence-counting method. because
the grapheme surface is a builtin extension str: (see
transpilation), it can never shadow a real str member β so
basedpython keeps python's count untouched and spells the grapheme count
character_count:
"mississippi".count("ss") # 2 β python's occurrence-counting method
"mississippi".character_count # 11 β grapheme count
len(s) is a third thing again β the code-point count β so the three never
conflate: len("πΊπΈ") == 2, "πΊπΈ".character_count == 1
the grapheme character view¶
the grapheme-aware surface reads and slices a string in whole Characters:
| accessor | result | notes |
|---|---|---|
s.character_count |
int |
number of grapheme clusters |
s.first / .last |
Character \| None |
first / last grapheme, None when empty |
s.characters |
Sequence[Character] |
the grapheme counterpart to list(s) β indexable and sized |
s.character_at(i) |
Character |
i-th grapheme (negative allowed; IndexError out of range) |
s.reversed |
str |
grapheme-safe reverse (unlike s[::-1]) |
s.drop_first() / .drop_last() |
str |
all but the first / last grapheme |
s.prefix(n) / .suffix(n) |
str |
first / last n graphemes (clamped, so prefix(0) == "") |
def f(s: str):
for ch in s.characters: # each `ch` is a whole grapheme
print(ch)
reveal_type(s.character_at(0)) # revealed: Character
reveal_type(s.reversed) # revealed: str
reveal_type(s.prefix(3)) # revealed: str
for example, with s = "aπΊπΈΓ©" (three graphemes, four code points):
s.character_count # 3
s.characters # ['a', 'πΊπΈ', 'Γ©']
s.character_at(1) # 'πΊπΈ' β the flag stays whole
s.reversed # 'Γ©πΊπΈa' β `s[::-1]` would corrupt the flag
s.prefix(2) # 'aπΊπΈ'
s.suffix(1) # 'Γ©'
the scalar view¶
str also has a scalar view β the unicode code points β reached with plain
python string operations. len(s), s[i], s[i:j], for c in s and
reversed(s) all operate on code points, and s.unicode_scalars is an explicit
code-point iterator:
def f(s: str):
n = len(s) # code-point count (the scalar length)
for u in s.unicode_scalars: # iterate code points
print(u)
this is a deliberate two-view model β a grapheme (character) view and a code-point (scalar) view. the two views count differently for any multi-code-point grapheme:
| string | s.character_count (graphemes) |
len(s) (scalars) |
|---|---|---|
"a" |
1 | 1 |
"πΊπΈ" |
1 | 2 |
"π€¦πΌββοΈ" |
1 | 5 |
the two views are cleanly separated in the type system too: raw s[i] and
for c in s are typed str (they are code-point operations at runtime), so a
raw index never masquerades as a Character. that is why x: Character = s[0]
is an error β a code point is a str, not a guaranteed single grapheme β while
x: Character = s.character_at(0) is fine. reach for the character view when you
need whole graphemes; use the scalar view (s[i], s.unicode_scalars) when you
want code points
a fuller model where
stris declared aSequence[Character]β withlen, indexing, slicing and iteration all grapheme-based β is planned but not yet implemented. it needs those raw operations rewritten to graphemes at runtime to be sound, which is a larger change (see the transpiler design notes); until thenstrremains aSequence[str]and the grapheme surface lives in the accessors above
iterating a Character is reported¶
iterating over a Character yields its code points β for a multi-scalar
grapheme that is not the single character the reader sees β so it almost always
indicates a logic error (for example, code that meant to iterate the enclosing
string, or to treat the Character as an opaque unit). ty reports the
iteration-over-character lint (warn by default) at every syntactic iteration
site β for loops, comprehensions, unpacking, splats, and yield from:
scope¶
Character resolves implicitly only in type-expression positions β
annotations, return types, type aliases, class bases, and the other positions
the shared type-position walker recognises. in a value position it is an
ordinary identifier, and a local Character = β¦ binding shadows the implicit
name
Character is a real class at runtime: the transpiler emits class Character(str) whenever Character is imported or a grapheme accessor
constructs one, so isinstance(x, Character) works
there is exactly one Character class per process, so its identity survives
module boundaries β a Character built in one module is still a Character in
another. the emitted class is interned in a sys.modules registry to guarantee
that: isinstance tests class identity, so a plain per-module
class Character(str) would hand every module a different class and quietly
fail isinstance(value_from_another_module, Character)
first / last re-emit the receiver in both branches of the conditional; an
impure receiver (a call) is hoisted into a := temp so it is evaluated once.
first and last are one-way sugar β the reverse transpiler leaves their
python lowerings as plain expressions
transpilation¶
a from ty_extensions import Character β whether written by the user or injected
by an accessor β is turned into a concrete class Character(str) in the
preamble; the grapheme accessors lower to Character(...) constructions over
that class
the grapheme surface is a builtin extension str: β the character_count /
first / last / characters / reversed / unicode_scalars properties and
the character_at / drop_first / drop_last / prefix / suffix methods are
declared on str by the basedpython prelude, a vendored
ty_extensions/_prelude.byi stub every basedpython file sees without importing
it (folded in by applicable_extensions). because it is an extension it never
shadows a real str member, so python's str.count, str.__getitem__ and
str.__iter__ are left exactly as typeshed declares them, and the str base
stays Sequence[str]
these members are type-only: they have no backing function, so the
extension-call rewrite skips prelude members and the dedicated grapheme_string
lowering (the table above) emits the runtime python instead. the Character
class definition lives in ty_extensions