EntPy is a data access and privacy framework that augments SQLAlchemy, providing you a central place to safely access and manage your data.
Its purpose and name is directly "inspired" from the framework of the same name at Meta.
This is very much a work in progress. I only built the features I need for my own projects. If you need something else, definitely let me know and I'll try to send a quick PR.
In any case, you can use on of the 2 escape hatches:
# 1. Access a specific model directly
ent = await EntMyObject.gen(vc, ent_id)
ent.model # This is the raw SQLAlchemy object
# 2. Use SQLAlchemy
from entities.ent_my_object import EntMyObjectModel
session = await generate_session()
model = await session.get(EntMyObjectModel, ent_id)To get started with EntPy, you should first create an Ent. In order to do so, you need to create the definition file (or Ent Schema). Here is a short example, and you can see all the details in the Schema API section.
# In `./schemas/ent_my_object_schema.py`
from entpy.framework import Field, Schema, StringField
class EntMyObjectSchema(Schema):
def get_fields(self) -> list[Field]:
return [
StringField("my_field", length=100).not_null(),
]Run the gencode script (see Gencode section below for details):
uv run python ent_gencode.pyThe framework will generate a file in ./entities/ent_my_object.py that contains:
EntMyObject, the main class you will use to access the dataEntMyObjectQuery, a utility class that wraps the SQL Alchemy query API and enables you to query entsEntMyObjectMutator, a utility class to handle mutations (creation, update, deletion) for your ent in a safe wayEntMyObjectExample, a utility class for your tests to generate ents pre-popualted with test/example data
A ViewerContext (or VC) is a class that holds information about the identity of the person/service that is currently executing the code. It is used heavily in EntPrivacy to determine if the current viewer can access/modify the data.
There are 2 special ViewerContexts:
omniscient, which means you can see everythingall_powerful, which means you can see and do everything
Those VCs should be used as little as possible and only in situations where it is absolutely impossible to have the real identity of the viewer.
You have 2 ways to load an Ent:
- Use
gento load an Ent and returnNoneif it doesn't exist. - Use
genxto load an Ent and raise and error if it doesn't exist.
Both will check your privacy rules and only return the Ent if it can be accessed.
optional_ent = await EntMyObject.gen(vc, ent_id)
ent = await EntMyObject.genx(vc, ent_id)If you want to perform a more complex query to find one or more Ents, you can use the query API:
from ent_my_object import EntMyObject, EntMyObjectModel
from ent_my_other_object import EntMyOtherObjectModel
ents = (
await EntMyObject.query(vc)
.where(EntMyObjectModel.happiness_level == 3)
.where(EntMyObjectModel.sadness_level < 5)
.join(EntMyOtherObjectModel, EntMyOtherObjectModel.id == EntMyObjectModel.other_id)
.where(EntMyOtherObjectModel.some_other_field == "yolo")
.order_by(EntMyObjectModel.id.desc())
.limit(10)
.gen()
)The EntQuery wraps the SQL Alchemy query API so you can use the models to query everything!
You can also query for counts. Watch out! We do not run privacy rules when counting...
number = (
await EntMyObject.query_count(vc)
.where(EntMyObjectModel.happiness_level == 3)
.gen()
)ent = await EntMyObjectMutator.create(
vc=vc,
field1=val1,
field2=val2,
).gen_savex()
print(f"Created ent {ent.id}")Note that in order to make testing easier, Ents generate an "example" class that can be used like this:
ent = await EntMyObjectExample.gen_create(vc)
# Boom!
# An ent has been created, all it's fields have been populated appropriately, any edge has been created recursively, you are ready to use it fully.You can also choose to customize one or more fields:
ent = await EntMyObjectExample.gen_create(
vc=vc,
field42=value,
)mut = await EntMyObjectMutator.update(vc, ent)
mut.field1 = new_value
ent = await mut.gen_savex()
print(f"Updated ent {ent.id}")At the moment, we only support "HARD" deletes, meaning that the record is dropped from the DB.
await EntMyObjectMutator.delete(vc, ent).gen_save()
print(f"It's gone!")EntPy expects you to write "descriptors" for your data objects. It then uses those descriptors to generate all the code necessary to define and access the data in the database, to handle privacy, to handle session management, etc.
Descriptors can be Schemas, which are essentially concrete classes, or Patterns, which are abstract classes that schemas can implement.
At the minimum, a descriptor will require you to implement the get_fields function where you return the list of fields that this object has.
Fields have a set of common attributes, such as:
not_null(), which indicates that the field is not optionalexample(...), which enables the developer to provide an example for what the data for this field will look like. It is used in theEntExamplewhen generating data for the tests and is mandatory for required fields (that have been markednot_null).dynamic_example(lambda: ...), which is a more advanced version ofexample()that enables the developer to provide a dyanamically set example. It is useful for mandatory fields that have to be unique to make sure that each example has a different value.default, which is something that some fields support and allows you to define a default value for the field in case none is provided.unique(), which sets a unique index on that field and generates additional functions to get an Ent from that field:gen_from_xxxxandgenx_from_xxxx.
Then, we have a list of field types that are provided by the framework:
DatetimeFieldthat stores a datetime object. Note that we store all datetime with tz=UTC.
DatetimeField("my_date")EdgeFieldstores a reference to another Ent.
EdgeField("my_object", EntMyOtherObjectSchema)This field will be stored in the database as my_object_id: UUID and we will also generate a utility function async def gen_my_object(self) -> EntMyOtherObject to easily load the edge.
Note that you should not use a field name that ends with _id, this will be added for you automatically.
EnumFieldthat stores a python enum.
from enum import Enum
class EnumClass(Enum):
VAL1 = "VAL1"
VAL2 = "VAL2"
EnumField("my_enum", EnumClass)IntFieldthat stores an integer.
IntField("my_int")JsonFieldthat stores a JSON object. The second argument after the field name is the python type to which the content of the JsonField will be casted.
JsonField("my_json", "list[str]")
JsonField("my_json_2", "dict[str, str]")
JsonField("my_json_3", "dict[str, Any]")StringFieldthat stores a string. You need to pass the length of the string.
StringField("my_string", 100).example("Hello!")TextFieldthat stores a large string.
TextField("my_large_text")If you want your Ent to be immutable (can be read/created/deleted, but not updated), override the is_immutable function:
def is_immutable(self) -> bool:
return True// TODO write me: explain how the gencode works, and how to configure your gencode script
When you add EntPy to your project, you should write your own "gencode" file. It is the script that will get executed to generate the code based on your schemas and patterns.
Here is a sample file:
#!/usr/bin/env python3
from gencode.generator import run
if __name__ == "__main__":
run(
schemas_directory="./examples",
output_directory="./examples/generated",
base_import="from examples.database import Base",
session_getter_import="from examples.database import get_session",
session_getter_fn_name="get_session",
)Here are some details for the arguments:
schemas_directory: the directory in which the schemas and patterns will be stored. This is what EntPy will scan when trying to generate the code.output_directory: the directory in which the generated code will be stored.base_import: an import statement to be used to import theBasemodel from SQLAlchemy in your project. Seeexamples/database.pyfor an example.session_getter_import: an import statement used to import a function that will enable the framework to obtain a database session. Seeexamples/database.pyfor an example.session_getter_fn_name: the name of the function imported above.
Before contributing to this repository, it is recommended to add the pre-commit hook:
cd .git/hooks
ln -s ../../hooks/pre-commit .Always run ruff and mypy before committing:
uv run ruff format
uv run ruff check
uv run mypy .Run the tests:
PYTHONPATH=. uv run pytest examples/__tests__Run the examples with:
PYTHONPATH=. uv run python examples/run_gencode.pyBuild the project:
uv buildThe artifacts (tar.gz for the source distribution and wheel) are available in ./dist.
It can be installed in another project with:
uv add <path to the artifact>/entpy-<version>-py3-none-any.whl- support gen(x)_from_XXXX for unique fields in patterns
- check that the provided VC extends VC
- generate a list of UUID keys to load in patterns
- limit the limit in queries
- delete cascade?
- entquery in patterns
Those are things we may tackle later... maybe! Let us know if you're interested!
- Adding a function to
EntXXXCountQueryto compute the count in a privacy-aware way.