///|
/// What a caller may opt in to on top of PurePy.
///
/// PurePy is a subset of Python and a specification, and this module ships it
/// exactly. But an embedder is not always writing to the specification: one
/// wants `a and b and c`, another wants a `str` with methods, and forking the
/// library to get either is a bad trade. A `Profile` is how they ask.
///
/// Three things about it are load-bearing.
///
/// * **`core` is the default everywhere.** Every function that takes a
/// profile takes it as an optional argument defaulting to `core`, so a
/// caller who never heard of profiles gets PurePy, message for message and
/// verdict for verdict. That is not a courtesy: `test/golden/` and
/// `test/conform-policy.json` are the reference checker's answers, and a
/// profile that moved one of them would have broken the thing being ported.
///
/// * **A profile only ever ADDS.** It is built from `core` by adding
/// features, so there is no way to spell a SUBSET of PurePy with one, and a
/// program accepted under a poorer profile is accepted under a richer one.
///
/// * **A profile may open a `not_yet` gate and may not lift a
/// `prohibited` one.** The sieve says "not yet" to a form the specification
/// intends to have and says "prohibited" to one it excludes on purpose --
/// `for`, `while`, `try`, `raise`, `del`, `+=`, item and attribute
/// assignment. The second list is what makes PurePy pure, and no profile
/// touches it. See `lib/sieve/sieve.mbt`, where the two have separate
/// helpers for exactly this reason.
///
/// This package is a LEAF: it imports nothing from the library, not even
/// `value` or `ast`. It has to be, because the checker's `context` and the
/// evaluator's `value` both consult a profile and `value` imports `context`.
/// `tools/boundary-check.sh` asserts it.
///|
/// A feature PurePy does not have and a profile may add.
///
/// The doc comment on each arm cites the upstream issue the sieve cites, so
/// the two can be read together.
///
/// Arms are APPENDED, never inserted: the derived `Compare` is tag order, and
/// tag order is the order `Profile::features` answers in. Adding one is
/// additive for a caller who builds a profile and breaking for one who matches
/// a `Feature` exhaustively, which is the usual trade for an open enum.
pub(all) enum Feature {
/// `a and b and c`: three or more operands in one boolean expression (#82).
ChainedBoolean
/// `1 < x < 10`: more than one operator in one comparison (#82).
ChainedComparison
/// `x is None`, `x is not None` (#81). PurePy values have no identity, so
/// this is defined against `None` -- which is a singleton in Python and the
/// only thing `is` is reliably asked about -- and undefined elsewhere.
Identity
/// `xs[1:]`, `s[::-1]` (#59). Slicing is total where indexing is partial: a
/// bound past the end clamps rather than aborting.
Slicing
/// `a, b = 1, 2`, and the nested `a, (b, c) = ...` (#54). `*rest` is not
/// part of it: a starred expression is prohibited outright, so a target
/// under this feature is always exact in its arity.
///
/// A destructuring COMPREHENSION target -- `[a for a, b in items]` -- cites
/// the same issue and is a separate gate, still closed: it binds through
/// `eval_quals` rather than through an assignment, so it is its own rule.
DestructuringAssignment
/// Builtins beyond `print`, `len` and `range`: `abs`, `min`, `max`, `sum`,
/// `sorted`, `str`, `int`, `list` and the rest of
/// `@context.extra_builtin_names`.
///
/// No syntax changes. Every one of them is pure, and none of them coerces:
/// where Python would answer by truthiness or by treating a `bool` as a
/// number, this is undefined, because PurePy is.
ExtraBuiltins
/// The non-mutating methods of `str`, `list`, `tuple` and `dict`:
/// `"a".upper()`, `s.split(",")`, `xs.index(x)`, `d.get(k)`.
///
/// Methods are not first class. `"a".upper()` is a rule about the CALL, and
/// `f = "a".upper` stays the undefined operation it has always been -- a
/// bound method would be a new kind of value, needing a `repr`, an `eq` and
/// an ordering the specification does not define.
///
/// Only methods that answer with a NEW value are here. `append`, `sort`,
/// `update` and the rest of Python's mutating half are absent, and absent is
/// the same undefined operation an unknown attribute has always been. It
/// could not be otherwise: a `Value` is immutable and has no identity, so a
/// mutating method has nothing to write to.
BuiltinMethods
/// `def f(x=1)` and `lambda x=1: x` (#56), where the default is a LITERAL
/// int, float, str, bool or `None`.
///
/// The restriction is what makes the feature small. Python evaluates a
/// default once, when the `def` is executed, and that is observable: a
/// default of `print("hi")` prints once however often `f` is called. This
/// evaluator has nowhere to put a once -- calling one function of a mutual
/// region rebinds the whole region, so a `Def` closure is rebuilt per call
/// -- so a general default would have to be evaluated at the wrong time.
/// A literal has no side effect and reads no name, so WHEN it is evaluated
/// cannot be observed, and the question goes away instead of being answered
/// wrongly. A default that is not a literal stays refused.
DefaultArguments
/// `f"the answer is {x}"` (#55), with `!r` and `!s` conversions.
///
/// A format spec -- the `>10` of `f"{x:>10}"` -- is a language of its own,
/// and this does not implement it. Nor the `f"{x=}"` debug form, which
/// prints the source of the expression alongside its value. Both are still
/// refused, and named in the message so it is clear which half was missing.
FStrings
} derive(Eq, Compare, Hash, Debug)
///|
/// The name a `--profile` flag prints and a person reads.
pub fn Feature::name(self : Feature) -> String {
match self {
ChainedBoolean => "chained-boolean"
ChainedComparison => "chained-comparison"
Identity => "identity"
Slicing => "slicing"
DestructuringAssignment => "destructuring-assignment"
ExtraBuiltins => "extra-builtins"
BuiltinMethods => "builtin-methods"
DefaultArguments => "default-arguments"
FStrings => "f-strings"
}
}
// ---------------------------------------------------------------------------
///|
/// Which superset of PurePy a caller opted in to.
///
/// A sorted set, because that is how the rest of this codebase gets a
/// deterministic order out of a set: `features` iterates it, and what
/// `pure-py profiles` prints has to be the same twice.
///
/// The field is readable, as every `pub struct` in this library is, but there
/// is no way to CONSTRUCT one except from `core` by `adding`. That is the
/// property worth having: a `Profile` is always a superset of PurePy, never a
/// subset, and no caller can spell one that takes something away.
///
/// `Eq` is derived because "did the caller ask for the same language I did?"
/// is a fair question; `Debug` because a failing test should be able to say
/// which profile it had. `adding` rather than `with` only because `with` is a
/// keyword.
pub struct Profile {
features : @sorted_set.SortedSet[Feature]
} derive(Eq, Debug)
///|
/// PurePy exactly as specified: no feature at all, and the default of every
/// function in this library that takes a profile.
///
/// A `let` rather than a `fn`, as `@analysis.empty` is, so that the default
/// argument on a dozen public functions allocates nothing.
pub let core : Profile = { features: @sorted_set.SortedSet::new(), }
///|
/// Whether this profile has a feature. The one question the sieve, the checker
/// and the evaluator ask.
pub fn Profile::has(self : Profile, f : Feature) -> Bool {
self.features.contains(f)
}
///|
/// This profile with more features. Stacking is this and nothing else.
pub fn Profile::adding(self : Profile, fs : Array[Feature]) -> Profile {
let mut s = self.features
for f in fs {
s = s.add(f)
}
{ features: s, }
}
///|
/// Every feature this profile has, in tag order.
pub fn Profile::features(self : Profile) -> Array[Feature] {
self.features.iter().collect()
}
///|
/// Whether every feature of `other` is a feature of this one.
///
/// What "builds on top of" means, as a question that can be asked rather than a
/// claim in a comment.
pub fn Profile::contains(self : Profile, other : Profile) -> Bool {
for f in other.features {
if !self.features.contains(f) {
return false
}
}
true
}
// ---------------------------------------------------------------------------
// The named profiles, each built from the one below it.
///|
/// The features upstream has already committed to, as far as they are
/// implemented here.
///
/// Everything in it is a form the sieve refuses today with an issue number --
/// `not yet supported (#82)` and its neighbours -- which makes it the profile
/// with the least to lose: upstream intends to make these core, so this SHRINKS
/// as each one lands rather than drifting away from the specification.
///
/// It grows one feature per commit until it covers the whole table in
/// `implementation-plan.md` ยง "After the plan: the pending features". Ask it
/// what it holds -- `Profile::features`, or `pure-py profiles` -- rather than
/// assuming a feature from that table is in it yet.
pub fn pending() -> Profile {
core.adding([
ChainedBoolean,
ChainedComparison,
Identity,
Slicing,
DestructuringAssignment,
DefaultArguments,
FStrings,
])
}
///|
/// A profile by the name a `--profile` flag or a playground selector uses.
/// `pending`, and a larger set of builtin functions.
///
/// Nothing here is syntax: a program written against this profile parses as
/// PurePy and would be refused only for the NAMES it uses. That makes it the
/// cheapest superset to reason about, and the one an embedder most often
/// wants -- three builtins is a small language to write in.
pub fn builtins() -> Profile {
pending().adding([ExtraBuiltins])
}
///|
/// `builtins`, and the non-mutating methods of the builtin types.
///
/// The largest superset here, and the one that makes PurePy read like Python
/// rather than like a calculator: `",".join(parts)`, `s.split()`,
/// `d.get(k, 0)`.
///
/// It does not make anything mutable. Every method it adds answers with a new
/// value, because a `Value` is immutable and has no identity, so there is
/// nothing a mutating one could write to -- see `lib/value/method.mbt`.
pub fn methods() -> Profile {
builtins().adding([BuiltinMethods])
}
///|
pub fn by_name(name : String) -> Profile? {
match name {
"core" | "purepy" => Some(core)
"pending" => Some(pending())
"builtins" => Some(builtins())
"methods" => Some(methods())
_ => None
}
}
///|
/// Every name `by_name` answers to, in the order they stack.
pub let names : Array[String] = ["core", "pending", "builtins", "methods"]
///|
/// Which named profile accepts a form the sieve refused, if any does.
///
/// Keyed on the feature STRING the sieve passes to `not_yet`, because
/// `@error.Kind::NotYetSupported` already carries it and nothing else names the
/// rule. That is also why this lives here and not in `lib/error`: a hint about
/// profiles is a rendering concern, and `Diagnostic::message` is compared
/// against the reference checker's output word for word.
///
/// The caller decides whether to show it. A run that asked for no profile
/// should print what it printed before profiles existed.
pub fn accepted_by(feature : String) -> String? {
match feature {
"chained boolean operator"
| "chained comparison"
| "identity operator (is/is not)"
| "slicing"
| "destructuring assignment"
| "default arguments"
| "f-strings" => Some("pending")
_ => None
}
}