From 3bfdba7ebfb84faf9f344f0b0a5e23f2df989aa3 Mon Sep 17 00:00:00 2001 From: Ryan-Loofy <63126328+Ryan-Loofy@users.noreply.github.com> Date: Wed, 9 Nov 2022 09:19:00 -0500 Subject: [PATCH] Initial commit for Cosmos DBT Project (#1) * Initial commit for Cosmos DBT Project * Add gitignore * Removed dbt packages * Removed dbt logs * Delete target * Remove DS_Stores * Delete ds_store --- .gitignore | 17 ++++++ Dockerfile | 9 +++ Makefile | 6 ++ README.md | 59 +++++++++++++++++++ analysis/.gitkeep | 0 data/.gitkeep | 0 dbt_project.yml | 44 ++++++++++++++ docker-compose.yml | 9 +++ macros/.gitkeep | 0 macros/create_sps.sql | 6 ++ macros/create_udfs.sql | 15 +++++ macros/custom_naming_macros.sql | 11 ++++ macros/run_sp_create_prod_clone.sql | 7 +++ macros/streamline/api_integrations.sql | 11 ++++ macros/streamline/get_base_table_udtf.sql | 24 ++++++++ macros/streamline/sp_get_blocks_history.sql | 26 ++++++++ macros/streamline/sp_get_blocks_realtime.sql | 26 ++++++++ macros/streamline/streamline_udfs.sql | 18 ++++++ macros/tests/sequence_gaps.sql | 34 +++++++++++ macros/tests/tx_gaps.sql | 33 +++++++++++ macros/utils.sql | 35 +++++++++++ .../silver/streamline/streamline__blocks.sql | 19 ++++++ .../streamline/streamline__blocks_history.sql | 40 +++++++++++++ .../streamline__blocks_realtime.sql | 32 ++++++++++ .../streamline__complete_blocks.sql | 54 +++++++++++++++++ models/sources.yml | 9 +++ packages.yml | 6 ++ profiles.yml | 19 ++++++ snapshots/.gitkeep | 0 tests/.gitkeep | 0 30 files changed, 569 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 analysis/.gitkeep create mode 100644 data/.gitkeep create mode 100644 dbt_project.yml create mode 100644 docker-compose.yml create mode 100644 macros/.gitkeep create mode 100644 macros/create_sps.sql create mode 100644 macros/create_udfs.sql create mode 100644 macros/custom_naming_macros.sql create mode 100644 macros/run_sp_create_prod_clone.sql create mode 100644 macros/streamline/api_integrations.sql create mode 100644 macros/streamline/get_base_table_udtf.sql create mode 100644 macros/streamline/sp_get_blocks_history.sql create mode 100644 macros/streamline/sp_get_blocks_realtime.sql create mode 100644 macros/streamline/streamline_udfs.sql create mode 100644 macros/tests/sequence_gaps.sql create mode 100644 macros/tests/tx_gaps.sql create mode 100644 macros/utils.sql create mode 100644 models/silver/streamline/streamline__blocks.sql create mode 100644 models/silver/streamline/streamline__blocks_history.sql create mode 100644 models/silver/streamline/streamline__blocks_realtime.sql create mode 100644 models/silver/streamline/streamline__complete_blocks.sql create mode 100644 models/sources.yml create mode 100644 packages.yml create mode 100644 profiles.yml create mode 100644 snapshots/.gitkeep create mode 100644 tests/.gitkeep diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2045f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ + +target/ +dbt_modules/ +# newer versions of dbt use this directory instead of dbt_modules for test dependencies +dbt_packages/ +logs/ + +.venv/ +.python-version + +# Visual Studio Code files +*/.vscode +*.code-workspace +.history/ +**/.DS_Store +.vscode/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7989ff2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM fishtownanalytics/dbt:1.0.0 +WORKDIR /support +RUN mkdir /root/.dbt +COPY profiles.yml /root/.dbt +RUN mkdir /root/cosmos +WORKDIR /cosmos +COPY . . +EXPOSE 8080 +ENTRYPOINT [ "bash"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2a695b8 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +SHELL := /bin/bash + +dbt-console: + docker-compose run dbt_console + +.PHONY: dbt-console \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a619b4 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +## Profile Set Up + +#### Use the following within profiles.yml + +---- + +```yml +cosmos: + target: dev + outputs: + dev: + type: snowflake + account: vna27887.us-east-1 + role: INTERNAL_DEV + user: + password: + region: us-east-1 + database: COSMOS_DEV + warehouse: DBT_EMERGENCY + schema: silver + threads: 12 + client_session_keep_alive: False + query_tag: + prod: + type: snowflake + account: vna27887.us-east-1 + role: DBT_CLOUD_COSMOS + user: + password: + region: us-east-1 + database: COSMOS + warehouse: DBT_EMERGENCY + schema: silver + threads: 12 + client_session_keep_alive: False + query_tag: +``` +### Variables + +To control which external table environment a model references, as well as, whether a Stream is invoked at runtime using control variables: +* STREAMLINE_INVOKE_STREAMS +When True, invokes streamline on model run as normal +When False, NO-OP +* STREAMLINE_USE_DEV_FOR_EXTERNAL_TABLES +When True, uses DEV schema Streamline.Cosmos_DEV +When False, uses PROD schema Streamline.Cosmos + +Default values are False + +* Usage: +dbt run --var '{"STREAMLINE_USE_DEV_FOR_EXTERNAL_TABLES":True, "STREAMLINE_INVOKE_STREAMS":True}' -m ... + +### Resources: + +* Learn more about dbt [in the docs](https://docs.getdbt.com/docs/introduction) +* Check out [Discourse](https://discourse.getdbt.com/) for commonly asked questions and answers +* Join the [chat](https://community.getdbt.com/) on Slack for live discussions and support +* Find [dbt events](https://events.getdbt.com) near you +* Check out [the blog](https://blog.getdbt.com/) for the latest news on dbt's development and best practices diff --git a/analysis/.gitkeep b/analysis/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dbt_project.yml b/dbt_project.yml new file mode 100644 index 0000000..b2c0e72 --- /dev/null +++ b/dbt_project.yml @@ -0,0 +1,44 @@ +# Name your project! Project names should contain only lowercase characters +# and underscores. A good package name should reflect your organization's +# name or the intended use of these models +name: "cosmos_models" +version: "1.0.0" +config-version: 2 + +# This setting configures which "profile" dbt uses for this project. +profile: "cosmos" + +# These configurations specify where dbt should look for different types of files. +# The `source-paths` config, for example, states that models in this project can be +# found in the "models/" directory. You probably won't need to change these! +model-paths: ["models"] +analysis-paths: ["analysis"] +test-paths: ["tests"] +seed-paths: ["data"] +macro-paths: ["macros"] +snapshot-paths: ["snapshots"] + +target-path: "target" # directory which will store compiled SQL files +clean-targets: # directories to be removed by `dbt clean` + - "target" + - "dbt_modules" + - "dbt_packages" + +models: + +copy_grants: true + +on_schema_change: sync_all_columns + +tests: + +store_failures: true # all tests + +# Configuring models +# Full documentation: https://docs.getdbt.com/docs/configuring-models + +# In this example config, we tell dbt to build all models in the example/ directory +# as tables. These settings can be overridden in the individual model files +# using the `{{ config(...) }}` macro. + +vars: + "dbt_date:time_zone": GMT + STREAMLINE_INVOKE_STREAMS: False + STREAMLINE_USE_DEV_FOR_EXTERNAL_TABLES: False diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e7cbece --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3.4" + +services: + dbt_console: + build: . + volumes: + - .:/cosmos + env_file: + - .env diff --git a/macros/.gitkeep b/macros/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/macros/create_sps.sql b/macros/create_sps.sql new file mode 100644 index 0000000..9e330b7 --- /dev/null +++ b/macros/create_sps.sql @@ -0,0 +1,6 @@ +{% macro create_sps() %} + {% if target.database == 'COSMOS' %} + CREATE SCHEMA IF NOT EXISTS _internal; + {{ sp_create_prod_clone('_internal') }}; + {% endif %} +{% endmacro %} \ No newline at end of file diff --git a/macros/create_udfs.sql b/macros/create_udfs.sql new file mode 100644 index 0000000..05f7bfa --- /dev/null +++ b/macros/create_udfs.sql @@ -0,0 +1,15 @@ +{% macro create_udfs() %} + {% set sql %} + CREATE schema if NOT EXISTS silver; + {{ create_udtf_get_base_table( + schema = "streamline" + ) }} + + {% endset %} + {% do run_query(sql) %} + {% set sql %} + {# {{ create_udf_get_cosmos_chainhead() }} #} + {{ create_udf_get_cosmos_blocks() }} + {% endset %} + {% do run_query(sql) %} +{% endmacro %} diff --git a/macros/custom_naming_macros.sql b/macros/custom_naming_macros.sql new file mode 100644 index 0000000..0f4a72c --- /dev/null +++ b/macros/custom_naming_macros.sql @@ -0,0 +1,11 @@ +{% macro generate_schema_name(custom_schema_name=none, node=none) -%} + {% set node_name = node.name %} + {% set split_name = node_name.split('__') %} + {{ split_name[0] | trim }} +{%- endmacro %} + +{% macro generate_alias_name(custom_alias_name=none, node=none) -%} + {% set node_name = node.name %} + {% set split_name = node_name.split('__') %} + {{ split_name[1] | trim }} +{%- endmacro %} diff --git a/macros/run_sp_create_prod_clone.sql b/macros/run_sp_create_prod_clone.sql new file mode 100644 index 0000000..9844d20 --- /dev/null +++ b/macros/run_sp_create_prod_clone.sql @@ -0,0 +1,7 @@ +{% macro run_sp_create_prod_clone() %} +{% set clone_query %} +call cosmos._internal.create_prod_clone('cosmos', 'cosmos_dev', 'internal_dev'); +{% endset %} + +{% do run_query(clone_query) %} +{% endmacro %} \ No newline at end of file diff --git a/macros/streamline/api_integrations.sql b/macros/streamline/api_integrations.sql new file mode 100644 index 0000000..59b0470 --- /dev/null +++ b/macros/streamline/api_integrations.sql @@ -0,0 +1,11 @@ +{% macro create_aws_cosmos_api() %} + {% if target.name == "prod" %} + {% set sql %} + CREATE api integration IF NOT EXISTS aws_cosmos_api api_provider = aws_api_gateway api_aws_role_arn = 'arn:aws:iam::661245089684:role/snowflake-api-cosmos' api_allowed_prefixes = ( + 'https://e03pt6v501.execute-api.us-east-1.amazonaws.com/prod/', + 'https://mryeusnrob.execute-api.us-east-1.amazonaws.com/dev/' + ) enabled = TRUE; +{% endset %} + {% do run_query(sql) %} + {% endif %} +{% endmacro %} diff --git a/macros/streamline/get_base_table_udtf.sql b/macros/streamline/get_base_table_udtf.sql new file mode 100644 index 0000000..a488d14 --- /dev/null +++ b/macros/streamline/get_base_table_udtf.sql @@ -0,0 +1,24 @@ +{% macro create_udtf_get_base_table(schema) %} +create or replace function {{ schema }}.udtf_get_base_table(max_height integer) +returns table (height number) +as +$$ + with base as ( + select + row_number() over ( + order by + seq4() + ) as id + from + table(generator(rowcount => 100000000)) + ) +select + id as height +from + base +where + id <= max_height +$$ +; + +{% endmacro %} \ No newline at end of file diff --git a/macros/streamline/sp_get_blocks_history.sql b/macros/streamline/sp_get_blocks_history.sql new file mode 100644 index 0000000..ec02e05 --- /dev/null +++ b/macros/streamline/sp_get_blocks_history.sql @@ -0,0 +1,26 @@ +{% macro create_sp_get_blocks_history() %} + {% set sql %} + CREATE + OR REPLACE PROCEDURE streamline.sp_get_blocks_history() returns variant LANGUAGE SQL AS $$ +DECLARE + RESULT variant; +row_cnt INTEGER; +BEGIN + row_cnt:= ( + SELECT + COUNT(1) + FROM + {{ ref('streamline__blocks_history') }} + ); +if ( + row_cnt > 0 + ) THEN RESULT:= ( + SELECT + streamline.udf_get_blocks() + ); + ELSE RESULT:= NULL; +END if; +RETURN RESULT; +END;$$ {% endset %} +{% do run_query(sql) %} +{% endmacro %} diff --git a/macros/streamline/sp_get_blocks_realtime.sql b/macros/streamline/sp_get_blocks_realtime.sql new file mode 100644 index 0000000..4f95d2b --- /dev/null +++ b/macros/streamline/sp_get_blocks_realtime.sql @@ -0,0 +1,26 @@ +{% macro create_sp_get_blocks_realtime() %} + {% set sql %} + CREATE + OR REPLACE PROCEDURE streamline.sp_get_blocks_realtime() returns variant LANGUAGE SQL AS $$ +DECLARE + RESULT variant; +row_cnt INTEGER; +BEGIN + row_cnt:= ( + SELECT + COUNT(1) + FROM + {{ ref('streamline__blocks_realtime') }} + ); +if ( + row_cnt > 0 + ) THEN RESULT:= ( + SELECT + streamline.udf_get_blocks() + ); + ELSE RESULT:= NULL; +END if; +RETURN RESULT; +END;$$ {% endset %} +{% do run_query(sql) %} +{% endmacro %} diff --git a/macros/streamline/streamline_udfs.sql b/macros/streamline/streamline_udfs.sql new file mode 100644 index 0000000..2483c11 --- /dev/null +++ b/macros/streamline/streamline_udfs.sql @@ -0,0 +1,18 @@ +{% macro create_udf_get_cosmos_blocks() %} + CREATE + OR REPLACE EXTERNAL FUNCTION streamline.udf_get_cosmos_blocks( + json variant + ) returns text api_integration = aws_cosmos_api AS {% if target.name == "prod" %} + 'https://e03pt6v501.execute-api.us-east-1.amazonaws.com/prod/bulk_get_cosmos_blocks' + {% else %} + 'https://mryeusnrob.execute-api.us-east-1.amazonaws.com/dev/bulk_get_cosmos_blocks' + {%- endif %}; +{% endmacro %} + +{# {% macro create_udf_get_cosmos_chainhead() %} + CREATE EXTERNAL FUNCTION IF NOT EXISTS streamline.udf_get_chainhead() returns variant api_integration = aws_cosmos_api AS {% if target.name == "prod" %} + 'https://e03pt6v501.execute-api.us-east-1.amazonaws.com/prod/get_cosmos_chainhead' + {% else %} + 'https://mryeusnrob.execute-api.us-east-1.amazonaws.com/dev/get_cosmos_chainhead' + {%- endif %}; +{% endmacro %} #} diff --git a/macros/tests/sequence_gaps.sql b/macros/tests/sequence_gaps.sql new file mode 100644 index 0000000..9425003 --- /dev/null +++ b/macros/tests/sequence_gaps.sql @@ -0,0 +1,34 @@ +{% test sequence_gaps( + model, + partition_by, + column_name +) %} +{%- set partition_sql = partition_by | join(", ") -%} +{%- set previous_column = "prev_" ~ column_name -%} +WITH source AS ( + SELECT + {{ partition_sql + "," if partition_sql }} + {{ column_name }}, + LAG( + {{ column_name }}, + 1 + ) over ( + {{ "PARTITION BY " ~ partition_sql if partition_sql }} + ORDER BY + {{ column_name }} ASC + ) AS {{ previous_column }} + FROM + {{ model }} +) +SELECT + {{ partition_sql + "," if partition_sql }} + {{ previous_column }}, + {{ column_name }}, + {{ column_name }} - {{ previous_column }} + - 1 AS gap +FROM + source +WHERE + {{ column_name }} - {{ previous_column }} <> 1 +ORDER BY + gap DESC {% endtest %} diff --git a/macros/tests/tx_gaps.sql b/macros/tests/tx_gaps.sql new file mode 100644 index 0000000..a9d93df --- /dev/null +++ b/macros/tests/tx_gaps.sql @@ -0,0 +1,33 @@ +{% macro tx_gaps( + model + ) %} + WITH block_base AS ( + SELECT + block_number, + tx_count + FROM + {{ ref('silver__blocks') }} + ), + model_name AS ( + SELECT + block_number, + COUNT( + DISTINCT tx_hash + ) AS model_tx_count + FROM + {{ model }} + GROUP BY + block_number + ) +SELECT + block_base.block_number, + tx_count, + model_name.block_number AS model_block_number, + model_tx_count +FROM + block_base + LEFT JOIN model_name + ON block_base.block_number = model_name.block_number +WHERE + tx_count <> model_tx_count +{% endmacro %} diff --git a/macros/utils.sql b/macros/utils.sql new file mode 100644 index 0000000..85549f1 --- /dev/null +++ b/macros/utils.sql @@ -0,0 +1,35 @@ +{% macro if_data_call_function( + func, + target + ) %} + {% if var( + "STREAMLINE_INVOKE_STREAMS" + ) %} + {% if execute %} + {{ log( + "Running macro `if_data_call_function`: Calling udf " ~ func ~ " on " ~ target, + True + ) }} + {% endif %} + SELECT + {{ func }} + WHERE + EXISTS( + SELECT + 1 + FROM + {{ target }} + LIMIT + 1 + ) + {% else %} + {% if execute %} + {{ log( + "Running macro `if_data_call_function`: NOOP", + False + ) }} + {% endif %} + SELECT + NULL + {% endif %} +{% endmacro %} diff --git a/models/silver/streamline/streamline__blocks.sql b/models/silver/streamline/streamline__blocks.sql new file mode 100644 index 0000000..d47ab7d --- /dev/null +++ b/models/silver/streamline/streamline__blocks.sql @@ -0,0 +1,19 @@ +{{ config ( + materialized = "view", + tags = ['streamline_view'] +) }} + + +{% if execute %} +{# {% set height = run_query('SELECT streamline.udf_get_cosmos_chainhead()') %} +{% set block_height = height.columns[0].values()[0] %} +{% else %} #} +{% set block_height = 12000000 %} +{% endif %} + +SELECT + height as block_number +FROM + TABLE(streamline.udtf_get_base_table({{block_height}})) +WHERE + height >= 1000000 -- Highest block the archive has available \ No newline at end of file diff --git a/models/silver/streamline/streamline__blocks_history.sql b/models/silver/streamline/streamline__blocks_history.sql new file mode 100644 index 0000000..9898f24 --- /dev/null +++ b/models/silver/streamline/streamline__blocks_history.sql @@ -0,0 +1,40 @@ +{{ config ( + materialized = "view", + post_hook = if_data_call_function( + func = "{{this.schema}}.udf_get_cosmos_blocks(object_construct('sql_source', '{{this.identifier}}'))", + target = "{{this.schema}}.{{this.identifier}}" + ) +) }} + +{% for item in range(12) %} + ( + + SELECT + {{ dbt_utils.surrogate_key( + ['block_number'] + ) }} AS id, + block_number + FROM + {{ ref("streamline__blocks") }} + WHERE + block_number BETWEEN {{ item * 1000000 + 1 }} + AND {{( + item + 1 + ) * 1000000 }} + EXCEPT + SELECT + id, + block_number + FROM + {{ ref("streamline__complete_blocks") }} + WHERE + block_number BETWEEN {{ item * 1000000 + 1 }} + AND {{( + item + 1 + ) * 1000000 }} + ORDER BY + block_number + ) {% if not loop.last %} + UNION ALL + {% endif %} +{% endfor %} diff --git a/models/silver/streamline/streamline__blocks_realtime.sql b/models/silver/streamline/streamline__blocks_realtime.sql new file mode 100644 index 0000000..f8d7594 --- /dev/null +++ b/models/silver/streamline/streamline__blocks_realtime.sql @@ -0,0 +1,32 @@ +{{ config ( + materialized = "view", + post_hook = if_data_call_function( + func = "{{this.schema}}.udf_get_cosmos_blocks(object_construct('sql_source', '{{this.identifier}}'))", + target = "{{this.schema}}.{{this.identifier}}" + ) +) }} + +SELECT + {{ dbt_utils.surrogate_key( + ['block_number'] + ) }} AS id, + block_number +FROM + {{ ref("streamline__blocks") }} +WHERE + block_number > 12000000 + AND block_number IS NOT NULL +EXCEPT +SELECT + id, + block_number +FROM + {{ ref("streamline__complete_blocks") }} +WHERE + block_number > 12000000 +{# UNION ALL +SELECT + id, + block_number +FROM + {{ ref("streamline__blocks_history") }} #} diff --git a/models/silver/streamline/streamline__complete_blocks.sql b/models/silver/streamline/streamline__complete_blocks.sql new file mode 100644 index 0000000..a580e94 --- /dev/null +++ b/models/silver/streamline/streamline__complete_blocks.sql @@ -0,0 +1,54 @@ +{{ config ( + materialized = "incremental", + unique_key = "id", + cluster_by = "ROUND(block_number, -3)", + merge_update_columns = ["id"] +) }} + +WITH meta AS ( + + SELECT + last_modified, + file_name + FROM + TABLE( + information_schema.external_table_files( + table_name => '{{ source( "bronze_streamline", "blocks") }}' + ) + ) A +) + +{% if is_incremental() %}, +max_date AS ( + SELECT + COALESCE(MAX(_INSERTED_TIMESTAMP), '1970-01-01' :: DATE) max_INSERTED_TIMESTAMP + FROM + {{ this }}) + {% endif %} + SELECT + {{ dbt_utils.surrogate_key( + ['block_number'] + ) }} AS id, + block_number, + last_modified AS _inserted_timestamp + FROM + {{ source( + "bronze_streamline", + "blocks" + ) }} + JOIN meta b + ON b.file_name = metadata$filename + +{% if is_incremental() %} +WHERE + b.last_modified > ( + SELECT + max_INSERTED_TIMESTAMP + FROM + max_date + ) +{% endif %} + +qualify(ROW_NUMBER() over (PARTITION BY id +ORDER BY + _inserted_timestamp DESC)) = 1 diff --git a/models/sources.yml b/models/sources.yml new file mode 100644 index 0000000..4739f72 --- /dev/null +++ b/models/sources.yml @@ -0,0 +1,9 @@ +version: 2 + +sources: + - name: bronze_streamline + database: streamline + schema: | + {{ "COSMOS_DEV" if var("STREAMLINE_USE_DEV_FOR_EXTERNAL_TABLES", False) else "COSMOS" }} + tables: + - name: blocks diff --git a/packages.yml b/packages.yml new file mode 100644 index 0000000..9284fda --- /dev/null +++ b/packages.yml @@ -0,0 +1,6 @@ +packages: + - package: calogica/dbt_expectations + version: [">=0.4.0", "<0.9.0"] + - package: dbt-labs/dbt_utils + version: 0.9.2 + diff --git a/profiles.yml b/profiles.yml new file mode 100644 index 0000000..f5ba723 --- /dev/null +++ b/profiles.yml @@ -0,0 +1,19 @@ +cosmos: + target: dev + outputs: + dev: + type: snowflake + account: "{{ env_var('SF_ACCOUNT') }}" + # User/password auth + user: "{{ env_var('SF_USERNAME') }}" + password: "{{ env_var('SF_PASSWORD') }}" + role: "{{ env_var('SF_ROLE') }}" + schema: "{{ env_var('SF_SCHEMA') }}" + region: "{{ env_var('SF_REGION') }}" + database: "{{ env_var('SF_DATABASE') }}" + warehouse: "{{ env_var('SF_WAREHOUSE') }}" + threads: 4 + client_session_keep_alive: False + query_tag: cosmos_curator + config: + send_anonymous_usage_stats: False \ No newline at end of file diff --git a/snapshots/.gitkeep b/snapshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29