The Seahorse Language
Seahorse is based on Python 3, but only supports a subset of the full Python language. It has some additional constraints on what is and isn't allowed.
Limitations: a brief overview
Seahorse tries as hard as possible to do everything that Python does. However, some things that Python can do aren't very feasibly translated to Rust. For example, Rust structs know all of their fields at compile-time, but Python objects can gain additional fields at runtime - it might be possible to support Python here, but it would come at a big runtime cost.
The most important distinction is static vs. dynamic typing, discussed here.
One feature of Python's that is (nearly) fully supported by Seahorse is the way that values are passed by alias. This basically means that if you have some value in a variable, re-assigning this value to another variable will simply make a secondary alias for the same data:
Note to program authors - this behavior is achieved by using Rust's interior mutability pattern, since Rust's mutability rules are much stricter than Python's. This means that there is a small extra runtime cost associated with operations on mutable values. "Mutable values" mean any data types that are mutable by Python/Seahorse - this includes most collections (lists, arrays) and custom accounts types. Strings and integers are both immutable, so you won't see any extra cost here.
There is a lot of room for optimization on the compiler side here, and hopefully Seahorse will be able to take advantage of it in the future. Until then, just know that Seahorse programs will always output slightly less efficient code, in order to adhere to Python's programming model.
The Seahorse prelude
When you first create a Seahorse project, a Python file called prelude.py is imported via from seahorse.prelude import *
. This file contains class/function definitions for everything built in to Seahorse, and is used to provide editors with autocompletion and serve as documentation for the things you can do with Seahorse. The following table briefly summarizes the classes/functions that are made available - check prelude.py for more details:
u8, u16, ... u128
, i8, i16, ... i128
, f64
Array[T, N]
Fixed-length array, like a Python list
but with a size. N
must be an integer literal. Can be created as through the class constructor (Array(Iter[T], u64)
where the second argument is the length) or the function constructor (array(...T)
) Arrays can be used in any function that accepts an iterable.
Pubkey
A 32-byte public key.
Account
, Signer
, Empty
, TokenAccount
, etc.
Python constructs and builtins
Only a subset of Python's language constructs and builtins are supported. Seahorse is actively trying to support as much of Python as possible.
Builtin functions all should behave how you would expect them to in Python, sometimes with some minor adjustments for typing purposes.
Classes and OOP
Classes allow you to organize your code in a more intuitive way. You've already seen classes being used to store on-chain data (by extending Account
), but they can do much more than this!
Here's what defining a simple class with some data and methods looks like:
You can then use the class just like you would in Python:
Automatic constructors with @dataclass
Constructors can be automatically generated for classes with the @dataclass
decorator:
A constructor will be generated for this class that takes a parameter for each field, in the order defined in the class. So the above Datapoint
constructor can be called like this:
Imports
You can stretch your codebase among multiple files and then use those files with imports. Like in Python, an import can refer to a precise object (class/function) in a module, a module itself, or a package which may contain modules and subpackages.
Seahorse (.py) files make up modules, and directories make up packages. You might have a codebase structured like this:
Then program.py can use util like this:
Note the lack of an __init__.py file - some Python features require this, but Seahorse doesn't - directories are treated as packages and .py files are treated as modules. This also means that if you have a package with some extra random Python code nested deeply inside it, Seahorse will attempt to read and parse it, so make sure that you know what you're doing when you import a package!
Other constructs
These are some of the weirder/more Pythonic language constructs that you can use in Seahorse:
List comprehensions Seahorse fully supports list comprehensions! Just like in Python, you can do things like
[i**2 for i in range(10)]
. Other types of comprehension (generator, set, dict) are not supported yet, but will be in the near future.F-strings Formatted strings work mostly like in Python, but the exact string you get might be unexpected and is subject to change. Namely, if you pass in a custom class as a parameter, Seahorse will translate this to use the class's derived Debug method under the hood, which might give you weird results. For now, you should only really count on using f-strings for ad-hoc debugging and logging information. The API will stabilize eventually.
Tuple assignment Seahorse supports tuple assignment exactly like Python does - you can iterate over lists of tuples with
for (x, y) in ...
, and you can unpack tuples withx, y = ...
. You can even do the Pythonic one-line swap:x, y = y, x
.Functional programming and functions as first-class objects Partially supported. New in v2, you can do things that rely on functional programming - namely
map
andfilter
(see Builtins for working with iterators). Functions are not first-class objects in Seahorse, though, so you may not assign a function to a variable and pass it around that way.
General builtins
print(...T) -> None
Print a message. Under the hood, Seahorse uses the built-inmsg!
macro to log messages to Solana.str(T) -> str
Construct a string.list(Iter[T]) -> T
Construct a list from an iterable.
Builtins for working with numbers
abs({Numeric} T) -> T
Get the absolute value of a number.min(...{Numeric} T) -> T
Get the minimum of some numbers. Seahorse does not supportmin
's alternate iterable form.max(...{Numeric} T) -> T
Get the maximum of some numbers, also does not support the iterable form.round(f64) -> i128
Round a floating-point number to the nearest integer.
Builtins for working with iterators
Note that Iter[T]
includes any type that can be iterated over, like lists and arrays.
len(Iter[T]) -> u64
Get the length of an iterable.enumerate(Iter[T]) -> Iter[(u64, T)]
Obtain an iterator that also gives you the index of each item.filter((T) -> bool, Iter[T]) -> Iter[T]
Obtain an iterator that filters out certain elements.map((T) -> U, Iter[T]) -> Iter[U]
Obtain an iterator that transforms each element of the original iterable.range({Numeric} T, {Numeric} T?, {Numeric} T?) -> Iter[T]
Obtain an iterator over a range of numbers. Like in Python,range(a)
counts from 0 toa
(exclusive),range(a, b)
counts froma
tob
, andrange(a, b, k)
counts froma
tob
in increments ofk
.sorted(Iter[T]) -> List[T]
Obtain a sorted list from an iterable.sum(Iter[{Numeric} T]) -> T
Get the sum of the elements in an iterable.zip(Iter[T], Iter[U]) -> Iter[(T, U)]
Obtain an iterator that traverses two iterators simultaneously. Seahorse does not support a variadic amount of iterators like in Python.
Type hints and static typing
In Python, you can provide optional type hints on variables assignments, class fields, and function parameters. These hints are completely ignored by Python when your code runs, but your editor might make use of them to allow autocomplete and other features that rely on knowing the types of objects.
In Seahorse, type hints are occasionally mandatory in order to allow the underlying Rust code to be statically typed - that is, typed at compile time. The following table summarizes when you have to (or might want to) use type hints:
Class fields
ALWAYS, unless the class is an Enum.
Function parameters
ALWAYS.
Variable assignment
MAYBE, Rust has a powerful type inference system that can usually fill in the type of a variable when it is declared. If this isn't enough, you can provide a type when assigning a variable (var: Type = value
) and Seahorse will make sure the value is the right type.
Using Seahorse, most of your variables can be automatically typed as long as your class fields and function parameters are. Sometimes it might fail and you'll have to add a manual type or two, but for the most part you can rely on it to get you the result you expect.
Numbers and math
Rust has much stricter rules for doing simple math operations than Python - you can't add two different types of numbers, even if the only difference between them is their size (e.g. u8 vs. u64).
Seahorse preserves this while maintaining some flexibility by performing automatic numeric coercion in certain situations (mainly if you're performing arithmetic operations). Most mathematical operations will ensure the types of both operands are the same by coercing them to the less strict of the two types. In practice, this means that small integers will coerce to big integers, which will coerce to floating point numbers, and never the other way around. (This is essentially what Python does with math between int
s and float
s, but safely applied to more types.)
Seahorse supports every fixed-width int size and f64
. The automatic coercion rules are simple:
An unsigned int can be coerced to any wider (more bits) unsigned/signed int type
A signed int type can be coerced to any wider signed int type
Any int type can coerce to
f64
.
Like in Rust, an untyped integer is assigned a type based on usage. If you declare a variable x = 8
, Seahorse only knows that it belongs to some numeric type. If later, for instance, you pass x
to a function that expects a u64
as an argument, then Seahorse will assign the u64
type to x
. And if you then pass x
to a function that expects u16
as an argument, Seahorse will throw an error, because x
is a u64
.
Here are some examples to make all of that concrete:
There are two special operations to remember: /
(non-integer division) and **
(exponentation).
As shown above, non-integer division will always coerce both sides to floating-point (f64
), and returns an f64
.
Exponentiation has two modes: integer and non-integer. When doing base ** exponent
, Seahorse will decide to try either integer/non-integer exponentiation based on the type of base
. If base
is an integer, then exponent
will attempt to be coerced to a u32
. Otherwise, if base
is an f64
, then the exponent
will be cast to an f64
. You can't raise an integer to a non-u32
power.
Scoping rules
When your code gets compiled to Rust, certain rules need to be obeyed. Each declaration of a variable must have exactly one type, and scopes delineate where variables may be accessed. Python breaks both of those rules, resulting in runnable code like this:
...which will print "5" if condition
was true. Attempting to compile this to valid Rust code would be a mess, so instead Seahorse imposes Rust's rules onto Seahorse in the following way:
You may not reassign a variable to a new type if the assignment happens in a deeper scope.
Scoping works like in Rust, variables will be dropped as soon as the scope they're declared in ends.
Note that the first rule allows you to reassign a variable to a different type while in the same scope - you can still write code like this:
Scripts vs. modules
In a Python script, every top-level statement is run in sequence, and the result of the script is just whatever happens during those statements. If you import the script as a module, then the code just runs as usual and exposes all the new names to the importer.
In Seahorse, there is no concept of a script - the code you write is used to generate a Rust library, which is analagous to a Python module with some extra limitations. Statements can not be run unless they are part of a function that gets called. The following table summarizes what can do in your Seahorse file as a top-level statement:
Imports
Imports for Seahorse builtin libraries and local files
Class definitions
Arbitrary classes
Function definitions
Arbitrary functions
Instruction definitions
Functions decorated with @instruction
declare_id('...')
Directives
Although you can't put arbitrary top-level statements in your program, there are some special statements, known as directives, that allow you to control the compiler more than just generated code. (Right now the only directive is declare_id
, but more will be added in a future release!)
declare_id
Anchor has a Rust macro called declare_id!
that is needed to make sure your program knows its own key. When you seahorse init
a new project, the resulting .py file includes a declare_id
at the top:
However, this ID might change when you recompile with Anchor. This is an especially common hangup when testing your code for the first time.
All you need to do is fetch the new ID from /target/idl/<program>.json
:
...and paste it into the Seahorse declare_id
statement:
Last updated