Package: dtspec
Data Transform Specdtspec is an API for specifying and testing data transformations.IntroductionTesting data transformations is hard.  So hard that a lot of ETL/ELT
processes have little or (more often) no automated tests.
dtspec aims to make it easier to write and run tests for very complicated
data transformations typically encountered in ETL/ELT.With dtspec, we imagine a data transformation process that takes a set of
datasourcesand transforms them into a set of datatargets.  dtspec
is primarily concerned with structured data sources, like Pandas
dataframes or database tables.  A user of dtspec defines datafactoriesthat
generate source data, and a set ofexpectationsthat describe how the data
should look after it's been transformed.While dtspec is written in Python, it is intended to be used as more of a
language-agnostic API.  A dtspec user writes a testspec, which is then passed
to dtspec.  dtspec processes that spec and then returns to the user test data for
all of the source specific in the spec.  The user then feeds that test data into
their data transformation system, collects the output, and sends it back to dtspec.
dtspec compares the actual results of the data transformations with the expected
results specific in the spec and reports on any discrepancies.TutorialLet's see this all at work with some examples.Hello World!Let's suppose we have a dataset containing student records.  Our data
transformation simply reads in that data, and returns a new dataframe
with a "Hello " salutation.  We want to test that it says
"hello" to everyone.  For the purposes of our tutorial, the data
transformation will be written in Pandas asdefhello_world_transformer(raw_students):salutations_df=raw_students.copy()salutations_df["salutation"]=salutations_df['name'].apply(lambdav:'Hello '+v)return{"salutations":salutations_df}dtspec is an API that accepts a JSON blob for the transformation spec.  However, I strongly
prefer to write specs in YAML and then convert them into JSON before passing them
on to dtspec.  To begin writing our transform spec, we define the dtspecversion, adescriptionof the transform spec, and then list out thesourcesandtargets:---version:'0.1'description:HelloWorld - Simplest example of running dtspec# The names of sources and targets is arbitrary, but it's up to the user to determine# how they get mapped to/from their data transformation system.sources:-source:raw_studentstargets:-target:salutationsThese define our inputs and outputs.  But we also need to define how to generate
data for the input(s).  For that, we define afactory:factories:-factory:SomeStudentsdescription:Minimal example of what some student records may look likedata:-source:raw_students# Tables written as a markdown tabletable:|| id | name   || -  | -      || 1  | Buffy  || 2  | Willow |Lastly, we need to describe how we expect the data to look after it has been transformed.
To do this, we definescenariosandcases.  Scenarios are collections of cases
that share some common data factory or describe similar situations.  For now, our
transform spec will just contain a single scenario and a single case:scenarios:-scenario:Hello Worlddescription:The simplest scenario# All cases in this scenario will use this factory (which may be modified on case-by-case basis)factory:parents:-SomeStudentscases:-case:HelloGangdescription:Make sure we say hello to everyoneexpected:data:-target:salutations# The actual output may also contain the "name" field, but the expectation# will ignore comparing any fields not listed in the expected table.table:|| id | salutation   || -  | -            || 1  | Hello Buffy  || 2  | Hello Willow |That's it. See also thefull YAML spec.Now that we've described the full transform spec, we need to use it.  The first step is to
parse the YAML file, send it to the dtspec api, and have dtspec generate source data:importdtspecimportyamlspec=yaml.safe_load(open("tests/hello_world.yml"))api=dtspec.api.Api(spec)api.generate_sources()The specific steps taken at this point are going to be sensitive to the data transformation
environment being used, but we'll stick with our Pandas transformations for the sake of this
tutorial.  Given this, we can define a simple function that converts the source data returned
from dtspec into Pandas dataframes:importpandasaspddefparse_sources(sources):"Converts test data returned from dtspec api into Pandas dataframes"return{source_name:pd.DataFrame.from_records(data.serialize())forsource_name,datainsources.items()}We can then run those test Pandas dataframes through our data transformation function.sources_data=parse_sources(api.spec["sources"])actual_data=hello_world_transformer(**sources_data)Next, we need to convert the output dataframes of the transformations,actual_data,
back into a format that can be loaded into dtspec for comparison.  For Pandas,
this function is:defserialize_actuals(actuals):"Converts Pandas dataframe results into form needed to load dtspec api actuals"return{target_name:json.loads(dataframe.astype(str).to_json(orient="records"))fortarget_name,dataframeinactuals.items()}It is loaded into dtspec using:serialized_actuals=serialize_actuals(actual_data)api.load_actuals(serialized_actuals)Finally, dtspec can be called to run all of the expectations:api.assert_expectations()Putting all of this together:spec=yaml.safe_load(open("tests/hello_world.yml"))api=dtspec.api.Api(spec)api.generate_sources()sources_data=parse_sources(api.spec["sources"])actual_data=hello_world_transformer(**sources_data)serialized_actuals=serialize_actuals(actual_data)api.load_actuals(serialized_actuals)Try running the above code and changing either the YAML spec or thehello_world_transformerfunction and see how dtspec responds.Hello World With Multiple Test CasesRunning tests with multiple cases that reference the same data sources
introduces a complicating factor. One of the reasons that makes
it hard to build tests for ETL/ELT is the fact that many data
transformation systems in use today have a high latency for even very
small transformations.  For example, Redshift is a distributed RDBMS
that can process billions of rows in minutes, millions of rows in
seconds, thousands of rows in seconds, or 10s of rows in, well,
seconds.  Given these latency issues, we don't want to have to rely on
loading data into our system, running a test, clearing out the data,
loading some more, running the next test, and so on as is often
done when testing ORM-based applications like Rails or Django.dtspec seeks to minimize the number of requests on the data
transformation system in order to deal with these latency issues.
It does this by "stacking" the test data generated in each case
and delivering back to the user all of this stacked data.  The user
then loads this stacked data into their data transformation systemonce, runs the data transformationsonce, and then collects
the resulting outputonce.Let's see how dtspec handles this in action.First, let's change our hello world data transformation a bit.  Instead of
just saying hello to our heroes, let's say goodbye to any villians (as
identified by acliquedata field).defhello_world_multiple_transformer(raw_students):defsalutation(row):ifrow["clique"]=="Scooby Gang":return"Hello{}".format(row["name"])return"Goodbye{}".format(row["name"])salutations_df=raw_students.copy()salutations_df["salutation"]=salutations_df.apply(salutation,axis=1)return{"salutations":salutations_df}While it would be possible to test saying hello or goodbye in a single
case just by adding more records to the source data, we'll split it
into two to demonstrate how multiple cases work.  Here's how the YAML would look:scenarios:-scenario:Hello World With Multiple Casesdescription:The simplest scenariofactory:parents:-SomeStudentscases:-case:HelloGangdescription:Make sure we say hello to everyoneexpected:data:-target:salutationstable:|| id | name   | clique      | salutation   || -  | -      | -           | -            || 1  | Buffy  | Scooby Gang | Hello Buffy  || 2  | Willow | Scooby Gang | Hello Willow |-case:GoodbyeVilliansdescription:Say goodbye to villians# For this case, we tweak the factory defined for the scenario.factory:# The ids here might be the same as above.  However, these are just named# references and get translated into unique ids when the source data# is generated.data:-source:raw_studentstable:|| id | name     || -  | -        || 1  | Drusilla || 2  | Harmony  |# Use values to populate a constant over all recordsvalues:-column:cliquevalue:Vampiresexpected:data:# Again, the ids here are not the actual ids sent to dtspec after performing# the transformations.  They are just named references and dtspec# keeps track of the relationship between the actual ids and the named ones.-target:salutationstable:|| id | name     | clique   | salutation       || -  | -        | -        | -                || 1  | Drusilla | Vampires | Goodbye Drusilla || 2  | Harmony  | Vampires | Goodbye Harmony  |This won't quite work as is, because we're missing something.  We have
two cases that describe variations on the source dataraw_studentsand the outputsalutations.  dtspec collects the source data
definitions from each case and stacks them into a single data source.
The user then runs the transformations on that source and generates a
single target to provide back to dtspec.  But dtspec has to know which record
belongs to which case.  To do this, we have to define anidentifierthat tells dtspec which columns should be used to identify
a record as belonging to a case.  A good identifier is often a primary
key that uniquely defines a record, but it is not strictly required to
be unique across all records.For this example, we'll define an identifier called "students" with a singleidentifier attributecalledidthat is a unique integer:identifiers:-identifier:studentsattributes:-field:idgenerator:unique_integerWe tell dtspec that this identifier is associated with theidcolumns of both
the source and the target via:sources:-source:raw_studentsidentifier_map:-column:ididentifier:name:studentsattribute:idtargets:-target:salutationsidentifier_map:-column:ididentifier:name:studentsattribute:idWith the sources and targets with identifiers, the values we see in
the source factories and target expectations are not the values that
are actually used in the data.  Instead, they are simplynamed
refereces.  For example, in the "HelloGang" case,id=1belongs to
Buffy andid=2belongs to Willow.  But when dtspec generates the source
data, the actual values may be 3 and 9, or 4 and 7, or something else.
Unique values are not generated in any deterministic manner -- each
run of dtspec can give a diferent set.  dtspec only guarantees that the
each named reference will be a unique integer (via thegeneratordefined in theidentifiersection).Futhermore, in the second case called "GoodbyeVillians", we see thatid=1belongs to Drusilla andid=2belongs to Harmony.  dtspec will
generate unique values for this case as well, and theywill notconflict with the values generated for the first case.  So dtspec will pass
back to the user 4 total records (Buffy, Willow, Drusilla, Harmony) with 4
different idsWith thefull YAML specdefined, we can
run the assertions in the same fashion as the the earlier examplespec=yaml.safe_load(open("tests/hello_world_multiple_cases.yml"))api=dtspec.api.Api(spec)api.generate_sources()sources_data=parse_sources(api.spec["sources"])actual_data=hello_world_multiple_transformer(**sources_data)serialized_actuals=serialize_actuals(actual_data)api.load_actuals(serialized_actuals)api.assert_expectations()Embedded IdentifiersIt is also possible to embed identifiers in the value of a particular column.
For example, suppose oursalutationcolumn said hello to theidinstead
of the name of the person.  To make this work, we have to put a particular
string pattern in the column that indicates the name of the identifier, the
attribute, and the named id -{identifier.attribute[named_id]}.  The
yaml spec would look like:-case:HelloGangdescription:Make sure we say hello to everyoneexpected:data:-target:salutationstable:|| id | name   | clique      | salutation             || -  | -      | -           | -                      || 1  | Buffy  | Scooby Gang | Hello {students.id[1]} || 2  | Willow | Scooby Gang | Hello {students.id[2]} |Therealistic examplediscussed below has another example
of using embedded identifiers.Notethat embedded identifiers cannot be used to associate records
with cases.  A target must have at least one column listed in theidentifier_mapsection.A More Realistic ExampleFinally, let's example a more realistic example that one might
encounter when building a data warehouse.  In these situations, we'll
have multiple sources, targets, scenarios, and cases.  Now suppose we
have a students table, where every student belongs to a school and
takes 0 to many classes.  Our goal is to create one denormalized table
that combines all of these data sources into one table.  Additionally,
we want to create a table that aggregates all of our students to give
a count of the students per school.  In Pandas, the data transformation
might look like:defrealistic_transformer(raw_students,raw_schools,raw_classes,dim_date):student_schools=raw_students.rename(columns={"id":"student_id","external_id":"card_id"}).merge(raw_schools.rename(columns={"id":"school_id","name":"school_name"}),how="inner",on="school_id",)student_classes=student_schools.merge(raw_classes.rename(columns={"name":"class_name"}),how="inner",on="student_id",).merge(dim_date.rename(columns={"date":"start_date"}),how="left",on="start_date")student_classes["student_class_id"]=student_classes.apply(lambdarow:"-".join([str(row["card_id"]),str(row["class_name"])]),axis=1)students_per_school=(student_schools.groupby(["school_name"]).size().to_frame(name="number_of_students").reset_index())return{"student_classes":student_classes,"students_per_school":students_per_school,}Given thefull YAML specdefined, we can again run
the data assertions using a familiar pattern:spec=yaml.safe_load(open("tests/realistic.yml"))api=dtspec.api.Api(spec)api.generate_sources()sources_data=parse_sources(api.spec["sources"])actual_data=hello_world_multiple_transformer(**sources_data)serialized_actuals=serialize_actuals(actual_data)api.load_actuals(serialized_actuals)api.assert_expectations()dbt supportdtspec also contains a CLI tool that can facilitate using it withdbt.
The CLI tools helps you set up a test environment, run dbt in that environment, and
execute the dbt tests.  The CLI tool currently only works for Postgres and Snowflake dbt
projects.See thedbt-container-skeletonfor a
working example.dtspec CLI ConfigAll of the dtspec files should be placed in a subdirectory of your dbt project:dbt/dtspec.
The first thing to set up for the dtspec CLI is the configuration file, which should
be placed indtspec/config.yml.  The configuration file tells dtspec how to recreate
the table schemas in a test environment, where to recreate the table schemas, and where
to find the results of a dbt run.  Here is an example:# A target environment is where the output of data transformations appear.# Typically, there will only be on target environment.target_environments:# The target environment IS NOT your production environment.  It needs to be a separate# database where dbt will run against the test data that dtspec generates.  The name# of this environment needs to be the same as a target defined in dbt profiles.yml (in this case `dtspec`)dtspec:# Field names here follow the same conventions as dbt profiles.yml (https://docs.getdbt.com/dbt-cli/configure-your-profile)type:postgreshost:"{{env_var('POSTGRES_HOST')}}"port:5432user:"{{env_var('POSTGRES_USER')}}"password:"{{env_var('POSTGRES_PASSWORD')}}"dbname:"{{env_var('POSTGRES_DBNAME')}}_dtspec"# A source environment is where source data is located.  It may be in the same database# as the target environment or it may be different if the data warehouse supports it (e.g., Snowflake).# It is also possible to define several source environments if your source data is spread# across multiple databases.source_environments:raw:# Use `tables` to specify source tables that need to be present to run tests.tables:# `wh_raw` is the name of a namespace (aka schema) in the `raw` source environmentwh_raw:# tables may be listed indivdually (or, use `wh_raw: '*'` to indicate all tables within the `wh_raw` namespace)-raw_customers-raw_orders-raw_payments# In order to run tests, we need to replicate the table schemas in the test environment.# The schema section here contains credentials for a database where those tables are defined.# This is likely a production database (in your warehouse), or is a production replica.# dtspec only uses this database to read reflect the table schemas (via `dtspec db --fetch-schemas`).schema:type:postgreshost:"{{env_var('POSTGRES_HOST')}}"port:5432user:"{{env_var('POSTGRES_USER')}}"password:"{{env_var('POSTGRES_PASSWORD')}}"dbname:"{{env_var('POSTGRES_DBNAME')}}"# The test section contains credentials for a database where test data will be created.# Data in this database is destroyed and rebuilt for every run of dtspec and SHOULD NOT be# the same as the schema credentials defined above.test:type:postgreshost:"{{env_var('POSTGRES_HOST')}}"port:5432user:"{{env_var('POSTGRES_USER')}}"password:"{{env_var('POSTGRES_PASSWORD')}}"dbname:"{{env_var('POSTGRES_DBNAME')}}_dtspec"# Pretending snapshots are in a different database because Postgres doesn't support cross-db queries.# This is how you would do it if snapshots were in a different database than other raw source data.snapshots:tables:snapshots:'*'schema:type:postgreshost:"{{env_var('POSTGRES_HOST')}}"port:5432user:"{{env_var('POSTGRES_USER')}}"password:"{{env_var('POSTGRES_PASSWORD')}}"dbname:"{{env_var('POSTGRES_DBNAME')}}"test:type:postgreshost:"{{env_var('POSTGRES_HOST')}}"port:5432user:"{{env_var('POSTGRES_USER')}}"password:"{{env_var('POSTGRES_PASSWORD')}}"dbname:"{{env_var('POSTGRES_DBNAME')}}_dtspec"Test environment setupOnce the configuration file has been defined, the next step is to fetch/reflect schemas for
the source tables.  From thedbtdirectory, run the following CLI command:dtspec db --fetch-schemasThis will query all of the databases defined in theschemasection of the source
environments defined indtspec/config.yml, and create table schema files indtspec/schemas.
The files in this directory should be committed to source control and updated whenever
your source data changes (in so much as it would affect the dtspec tests).Next, initialize the test databases defined in thetestsection of the source
environments defined indtspec/config.ymlwith the CLI commanddtspec db --init-test-dbThis will create empty source tables in your test databases, ready to be loaded with test data.Executing testsIn order to use dtspec with dbt, spec files must make use of thedbt_sourceanddbt_refJinja functions.  These are analogous to the dbtsourceandreffunctions.  dtspec
will compile your dbt project and use thedbt/target/manifest.jsonfile to resolve the names
of sources and targets that you want to test.  For example, the SomeStudents factory
would be written as follows if this were a dbt project:factories:-factory:SomeStudentsdata:-source:{{dbt_source('raw','raw_students')}}table:|| id | name   || -  | -      || 1  | Buffy  || 2  | Willow |and an expectation would be:cases:-case:HelloGangexpected:data:-target:{{dbt_ref('salutations')}}table:|| id | salutation   || -  | -            || 1  | Hello Buffy  || 2  | Hello Willow |With these references set, dtspec tests can be executed via the CLI command:dtspec test-dbtThis command will do the following:It will first compile your dbt project.  If your dbt code does not change between
dtspec tests, you may skip this step by pass the--partial-parseargument.The dtspec spec files are compiled into a single document and dbt references are resolved.
The compiled dtspec document is output todtspec/compiled_specs.yml, which does not
need to be saved to source control.Source data is generated and loaded into the test databases.dbt is executed against the test database.The models that dbt built in the target test environment are extracted.  These are the "actuals".The actuals are compared with the expected data as specified in the dtspec specs.Thetest-dbtcommand has several options that may be useful.  Seedtspec test-dbt -hfor a full
list, but here are some noteworthy options:--modelsspecifies the models that dbt should run, using standard dbt model selection syntax.--scenariosis used to restrict the number of scenarios that are tested.  The argument is a
regular expression that will match on the compiled Scenario name.  This can be used
in combination with the--modelscommand to only run those tests and models that you're
concerned with.Additonal CLI notesLog levelIf you want to see more detailed loggin information, set theDTSPEC_LOG_LEVELenvironment
variable (options are DEBUG, INFO, WARN, and ERROR).  For example:DTSPEC_LOG_LEVEL=INFO dtspec test-dbtProject locationIf you really don't want to put dtspec in the dbt project directory you can override the
default by settingDTSPEC_ROOTandDBT_ROOTenvironment variables that point
to the root path of these projects.Special ValuesWhen dtspec is run via the CLI, it recognizes nulls and booleans in the spec files.  To
indicate these kinds of values in a dtspec spec, use{NULL},{True}, and{False}.
For example:cases:-case:HelloGangexpected:data:-target:{{dbt_ref('salutations')}}table:|| id | salutation   | is_witch || -  | -            | -        || 1  | Hello Buffy  | {False}  || 2  | Hello Willow | {True}   || 3  | Hello NA     | {NULL}   |Jinja contextWhen writing spec files that will be parsed with the dtspec CLI, the following functions
are available in the jinja context:datetime-- This is thePython datetime.datetime typedate-- This is thePython datetime.date typerelativedelta-- This is thePython relativedelta type0UTCNOW-- The UTC datetime value at the time the specs are parsedTODAY-- The current UTC date value at the time the specs are parsedYESTERDAY-- Yesterday's dateTOMORROW-- Tomorrow's datedbt_source-- Used to reference dbt sourcesdbt_ref-- Used to reference dbt modelsSome example of using these functions:- source: raw_products
   table: |
    | export_time                          | file                    | product_id | product_name |
    | -                                    | -                       | -          | -            |
    | {{ YESTERDAY }}                      | products-2021-01-06.csv | milk       | Milk         |
    | {{ TODAY - relativedelta(days=5) }}  | products-2021-01-02.csv | milk       | Milk         |Additional notes about dtspecAt the moment, all source data values are generated as strings.  It
is up to the the user to enforce data types suitable to their data
transformation system.  Note that the dtspec dbt CLI commands handle this
for Postgres and Snowflake warehouses.Additionally, data expectations are stringified prior to running assertions.ContributingWe welcome contributors!  Please submit any suggests or pull requests in Github.Developer setupCreate an appropriate python environment.  I likeminiconda,
but use whatever you like:conda create --name dtspec python=3.8
conda activate dtspecThen install pip packagespip install pip-tools
pip install --ignore-installed -r requirements.txtrun tests viainv testand the linter viainv lint