diff --git a/.changes/unreleased/Features-20240131-152923.yaml b/.changes/unreleased/Features-20240131-152923.yaml new file mode 100644 index 00000000..fc649269 --- /dev/null +++ b/.changes/unreleased/Features-20240131-152923.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Cache static objects between tests to accelerate integration runs. +time: 2024-01-31T15:29:23.951857-05:00 +custom: + Author: peterallenwebb + Issue: "55" diff --git a/.changes/unreleased/Features-20240201-101851.yaml b/.changes/unreleased/Features-20240201-101851.yaml new file mode 100644 index 00000000..fdd88f5c --- /dev/null +++ b/.changes/unreleased/Features-20240201-101851.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Add dataclass utility `Mergeable` +time: 2024-02-01T10:18:51.474231-08:00 +custom: + Author: QMalcolm + Issue: "58" diff --git a/.changes/unreleased/Features-20240201-104437.yaml b/.changes/unreleased/Features-20240201-104437.yaml new file mode 100644 index 00000000..ff251faf --- /dev/null +++ b/.changes/unreleased/Features-20240201-104437.yaml @@ -0,0 +1,6 @@ +kind: Features +body: Added a new InvocationContext class for application context management. +time: 2024-02-01T10:44:37.161298-05:00 +custom: + Author: peterallenwebb + Issue: "57" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b50bd1c5..3a0f5eac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,7 @@ on: merge_group: types: [checks_requested] workflow_dispatch: + workflow_call: permissions: read-all diff --git a/.github/workflows/ci_dbt_core_testing.yml b/.github/workflows/ci_dbt_core_testing.yml index 5ee8fb31..b1dc3e02 100644 --- a/.github/workflows/ci_dbt_core_testing.yml +++ b/.github/workflows/ci_dbt_core_testing.yml @@ -2,52 +2,302 @@ # Runs all tests in dbt-core with this branch of dbt-common to ensure nothing is broken # **why?** -# Ensure dbt-common changes do nto break dbt-core +# Ensure dbt-common changes do not break dbt-core # **when?** # This will run when trying to merge a PR into main. # It can also be manually triggered. -## TODO: This is a stub. It does nothing right now. -# It will be updated in the future as part of https://github.com/dbt-labs/dbt-common/issues/18 +# This workflow can be skipped by adding the "Skip Core Testing" label to the PR. This is +# useful when making a change in both `dbt-core` and `dbt-common` where the changes are dependant +# and cause the other repository to break. -name: Test Against dbt-core +name: "dbt-core Tests" +run-name: >- + ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call') + && format('dbt-core@{0} with dbt-common@{1}', inputs.dbt-core-ref, inputs.dbt-common-ref) + || 'dbt-core@main with dbt-common branch' }} on: merge_group: types: [checks_requested] + pull_request: workflow_dispatch: + inputs: + dbt-core-ref: + description: "The branch of dbt-core to test against" + default: "main" + dbt-common-ref: + description: "The branch of dbt-common to test against" + default: "main" + workflow_call: + inputs: + dbt-core-ref: + description: "The branch of dbt-core to test against" + type: string + required: true + default: "main" + dbt-common-ref: + description: "The branch of dbt-common to test against" + type: string + required: true + default: "main" permissions: read-all +# will cancel previous workflows triggered by the same event +# and for the same ref for PRs/merges or same SHA otherwise +# and for the same inputs on workflow_dispatch or workflow_call +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ contains(fromJson('["pull_request", "merge_group"]'), github.event_name) && github.event.pull_request.head.ref || github.sha }}-${{ contains(fromJson('["workflow_call", "workflow_dispatch"]'), github.event_name) && github.event.inputs.dbt-core-ref && github.event.inputs.dbt-common-ref || github.sha }} + cancel-in-progress: true + defaults: run: shell: bash +# top-level adjustments can be made here +env: + # number of parallel processes to spawn for python integration testing + PYTHON_INTEGRATION_TEST_WORKERS: 5 + jobs: - test-dbt-core: - name: "This does nothing right now - always passes" + job-prep: + # This allow us to run the workflow on pull_requests as well so we can always run unit tests + # and only run integration tests on merge for time purposes + name: Setup Repo Refs + runs-on: ubuntu-latest + outputs: + dbt-core-ref: ${{ steps.core-ref.outputs.ref }} + dbt-common-ref: ${{ steps.common-ref.outputs.ref }} + + steps: + - name: "Input Refs" + id: job-inputs + run: | + echo "inputs.dbt-core-ref=${{ inputs.dbt-core-ref }}" + echo "inputs.dbt-common-ref=${{ inputs.dbt-common-ref }}" + + - name: "Determine dbt-core ref" + id: core-ref + run: | + if [[ -z "${{ inputs.dbt-core-ref }}" ]]; then + REF="main" + else + REF=${{ inputs.dbt-core-ref }} + fi + echo "ref=$REF" >> $GITHUB_OUTPUT + + - name: "Determine dbt-common ref" + id: common-ref + run: | + if [[ -z "${{ inputs.dbt-common-ref }}" ]]; then + # these will be commits instead of branches + if [[ "${{ github.event_name }}" == "merge_group" ]]; then + REF=${{ github.event.pull_request.merge_commit_sha }} + else + REF=${{ github.event.pull_request.base.sha }} + fi + else + REF=${{ inputs.dbt-common-ref }} + fi + echo "ref=$REF" >> $GITHUB_OUTPUT + + - name: "Final Refs" + run: | + echo "dbt-core-ref=${{ steps.core-ref.outputs.ref }}" + echo "dbt-common-ref=${{ steps.common-ref.outputs.ref }}" + + dbt-core-unit-test: + name: "dbt-core unit tests" + needs: [job-prep] runs-on: ubuntu-latest timeout-minutes: 10 steps: - - name: "Check out dbt-core" + - name: "Check out dbt-core@${{ needs.job-prep.outputs.dbt-core-ref }}" uses: actions/checkout@v4 + with: + repository: dbt-labs/dbt-core + ref: ${{ needs.job-prep.outputs.dbt-core-ref }} - name: "Set up Python 3.11" uses: actions/setup-python@v5 with: python-version: "3.11" - - name: "Update the version of dbt-common" + - name: "Upgrade pip" + run: | + python -m pip install --upgrade pip + python -m pip --version + + # tox takes care of installing the correct version of dbt-core dependencies but we need to + # install them first s that we can override the dbt-common branch + - name: "Manually install dbt-core dependencies" + run: | + python -m pip uninstall dbt-common -y + python -m pip install -r dev-requirements.txt -r editable-requirements.txt + + # Since the dbt-common dependency is pinned in dev-requirements.txt we need to force update it + # Since tox installs dependencies but doesn't force update, it won't get overridden in the next + # step since the requirements will already be met + - name: "Force update the version of dbt-common@${{ needs.job-prep.outputs.dbt-common-ref }}" + run: | + python -m pip install pip install git+https://github.com/dbt-labs/dbt-common.git@${{ needs.job-prep.outputs.dbt-common-ref }} --force-reinstall + + - name: "Run unit tests" + # Doing the check here instead of the top level because this is job a required check, the + # label just means we should skip the tests + if: ${{ !contains(github.event.label.name, 'Skip Core Testing')}} + run: tox + env: + TOXENV: unit + + - name: "Check installed versions" + run: pip freeze + + dbt-core-integration-metadata: + name: "integration test metadata generation" + runs-on: ubuntu-latest + if: ${{ github.event_name != 'pull_request' && !contains(github.event.label.name, 'Skip Core Testing')}} + outputs: + split-groups: ${{ steps.generate-split-groups.outputs.split-groups }} + include: ${{ steps.generate-include.outputs.include }} + + steps: + - name: "generate split-groups" + id: generate-split-groups + run: | + MATRIX_JSON="[" + for B in $(seq 1 ${{ env.PYTHON_INTEGRATION_TEST_WORKERS }}); do + MATRIX_JSON+=$(sed 's/^/"/;s/$/"/' <<< "${B}") + done + MATRIX_JSON="${MATRIX_JSON//\"\"/\", \"}" + MATRIX_JSON+="]" + echo "split-groups=${MATRIX_JSON}" + echo "split-groups=${MATRIX_JSON}" >> $GITHUB_OUTPUT + + - name: "generate include" + id: generate-include + run: | + INCLUDE=('"python-version":"3.8","os":"windows-latest"' '"python-version":"3.8","os":"macos-latest"' ) + INCLUDE_GROUPS="[" + for include in ${INCLUDE[@]}; do + for group in $(seq 1 ${{ env.PYTHON_INTEGRATION_TEST_WORKERS }}); do + INCLUDE_GROUPS+=$(sed 's/$/, /' <<< "{\"split-group\":\"${group}\",${include}}") + done + done + INCLUDE_GROUPS=$(echo $INCLUDE_GROUPS | sed 's/,*$//g') + INCLUDE_GROUPS+="]" + echo "include=${INCLUDE_GROUPS}" + echo "include=${INCLUDE_GROUPS}" >> $GITHUB_OUTPUT + + dbt-core-integration-tests: + name: "(${{ matrix.split-group }}) integration test / python ${{ matrix.python-version }} / ${{ matrix.os }}" + if: ${{ github.event_name != 'pull_request' && !contains(github.event.label.name, 'Skip Core Testing')}} + + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + needs: [job-prep, dbt-core-integration-metadata] + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + os: [ubuntu-20.04] + split-group: ${{ fromJson(needs.dbt-core-integration-metadata.outputs.split-groups) }} + include: ${{ fromJson(needs.dbt-core-integration-metadata.outputs.include) }} + env: + DBT_INVOCATION_ENV: github-actions + DBT_TEST_USER_1: dbt_test_user_1 + DBT_TEST_USER_2: dbt_test_user_2 + DBT_TEST_USER_3: dbt_test_user_3 + + steps: + - name: "Check out the repository@${{ needs.job-prep.outputs.dbt-core-ref }}" + uses: actions/checkout@v4 + with: + repository: dbt-labs/dbt-core + ref: ${{ needs.job-prep.outputs.dbt-core-ref }} + + - name: "Set up Python ${{ matrix.python-version }}" + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: "Set up postgres (linux)" + if: runner.os == 'Linux' + uses: ./.github/actions/setup-postgres-linux + + - name: "Set up postgres (macos)" + if: runner.os == 'macOS' + uses: ./.github/actions/setup-postgres-macos + + - name: "Set up postgres (windows)" + if: runner.os == 'Windows' + uses: ./.github/actions/setup-postgres-windows + + - name: "Upgrade pip" + run: | + python -m pip install --upgrade pip + python -m pip --version + + # tox takes care of installing the correct version of dbt-core dependencies but we need to + # install them first s that we can override the dbt-common branch + - name: "Manually install dbt-core dependencies" + run: | + python -m pip install -r dev-requirements.txt -r editable-requirements.txt + + # Since the dbt-common dependency is pinned in dev-requirements.txt we need to force update it + # Since tox installs dependencies but doesn't force update, it won't get overridden in the next + # step since the requirements will already be met + - name: "Force update the version of dbt-common@${{ needs.job-prep.outputs.dbt-common-ref }}" + run: | + python -m pip uninstall dbt-common -y + python -m pip install pip install git+https://github.com/dbt-labs/dbt-common.git@${{ needs.job-prep.outputs.dbt-common-ref }} --force-reinstall + + - name: "Run Functional tests" + run: tox -- --ddtrace + env: + TOXENV: integration + DBT_INVOCATION_ENV: github-actions + DBT_TEST_USER_1: dbt_test_user_1 + DBT_TEST_USER_2: dbt_test_user_2 + DBT_TEST_USER_3: dbt_test_user_3 + DD_CIVISIBILITY_AGENTLESS_ENABLED: true + DD_API_KEY: ${{ secrets.DATADOG_API_KEY }} + DD_SITE: datadoghq.com + DD_ENV: ci + DD_SERVICE: dbt-core + PYTEST_ADDOPTS: ${{ format('--splits {0} --group {1}', env.PYTHON_INTEGRATION_TEST_WORKERS, matrix.split-group) }} + + - name: "Check installed versions" + run: pip freeze + + integration-report: + if: ${{ always() }} + name: "dbt-core Integration Test Suite Report" + runs-on: ubuntu-latest + needs: [dbt-core-integration-tests] + steps: + - name: "Integration Tests Failed" + if: ${{ contains(needs.dbt-core-integration-tests.result, 'failure') || contains(needs.dbt-core-integration-tests.result, 'cancelled') }} + # when this is true the next step won't execute + run: | + echo "::notice title='Integration test suite failed'" + exit 1 + + - name: "Integration Tests Passed" + if: ${{ github.event_name != 'pull_request' }} run: | - echo "Update the version of dbt-common ref'd in dbt-core" + echo "::notice title='Integration test suite passed'" - - name: "Install dbt-core" + - name: "Integration Tests Skipped on Pull Request" + if: ${{ github.event_name == 'pull_request' && !contains(github.event.label.name, 'Skip Core Testing')}} run: | - echo "Install dbt-core with updated dbt-common ref" + echo "::notice title='Integration test suite skipped on Pull Requests - they will run on merge'" - - name: "Run Tests" + - name: "Integration Tests Skipped by Label" + if: ${{ contains(github.event.label.name, 'Skip Core Testing')}} run: | - echo "Running tests in future versions." + echo "::notice title='dbt-core test suite skipped due to Skip Core Testing label'" diff --git a/.github/workflows/ci_tests.yml b/.github/workflows/ci_tests.yml index 65e84006..30c9d0df 100644 --- a/.github/workflows/ci_tests.yml +++ b/.github/workflows/ci_tests.yml @@ -19,6 +19,7 @@ on: merge_group: types: [checks_requested] workflow_dispatch: + workflow_call: permissions: read-all @@ -64,7 +65,7 @@ jobs: - name: Upload Unit Test Coverage to Codecov if: ${{ matrix.python-version == '3.11' }} - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} flags: unit diff --git a/.github/workflows/scheduled_testing.yml b/.github/workflows/scheduled_testing.yml new file mode 100644 index 00000000..7739031b --- /dev/null +++ b/.github/workflows/scheduled_testing.yml @@ -0,0 +1,36 @@ +# **what?** +# The purpose of this workflow is to trigger CI to run for each +# on a regular cadence. This will also test core + common to prevent breaks. +# If the CI workflow fails, it will post to #dev-core-alerts to raise awareness. + +# **why?** +# Ensures dbt-common is always shippable and not broken. +# Also, can catch any dependencies shifting beneath us that might +# introduce breaking changes (could also impact Cloud). + +# **when?** +# Mainly on a schedule of 9:00, 13:00, 18:00 UTC everyday. +# Manual trigger can also test on demand + +name: Scheduled Testing [ placeholders] + +on: + # schedule: + # - cron: '0 9,13,18 * * *' # 9:00, 13:00, 18:00 UTC + workflow_dispatch: # for manual triggering + + +# no special access is needed +permissions: read-all + +jobs: + run_tests: + # uses: dbt-labs/actions/.github/workflows/release-branch-tests.yml@main + # with: + # workflows_to_run: '["ci_tests.yml", "build.yml", "ci_dbt_core_testing.yml"]' + # secrets: inherit + runs-on: ubuntu-latest + steps: + + - name: Placeholder + run: echo "This is a placeholder job" diff --git a/dbt_common/__about__.py b/dbt_common/__about__.py index 1663d823..9da8ef6a 100644 --- a/dbt_common/__about__.py +++ b/dbt_common/__about__.py @@ -1 +1 @@ -version = "0.1.1" +version = "0.1.3" diff --git a/dbt_common/clients/jinja.py b/dbt_common/clients/jinja.py index b04ffae9..44d3eade 100644 --- a/dbt_common/clients/jinja.py +++ b/dbt_common/clients/jinja.py @@ -16,6 +16,7 @@ import jinja2.parser # type: ignore import jinja2.sandbox # type: ignore +from dbt_common.tests import test_caching_enabled from dbt_common.utils.jinja import ( get_dbt_macro_name, get_docs_macro_name, @@ -504,9 +505,19 @@ def catch_jinja(node=None) -> Iterator[None]: raise +_TESTING_PARSE_CACHE: Dict[str, jinja2.Template] = {} + + def parse(string): + str_string = str(string) + if test_caching_enabled() and str_string in _TESTING_PARSE_CACHE: + return _TESTING_PARSE_CACHE[str_string] + with catch_jinja(): - return get_environment().parse(str(string)) + parsed = get_environment().parse(str(string)) + if test_caching_enabled(): + _TESTING_PARSE_CACHE[str_string] = parsed + return parsed def get_template( @@ -528,6 +539,15 @@ def render_template(template, ctx: Dict[str, Any], node=None) -> str: return template.render(ctx) +_TESTING_BLOCKS_CACHE: Dict[int, List[Union[BlockData, BlockTag]]] = {} + + +def _get_blocks_hash(text: str, allowed_blocks: Optional[Set[str]], collect_raw_data: bool) -> int: + """Provides a hash function over the arguments to extract_toplevel_blocks, in order to support caching.""" + allowed_tuple = tuple(sorted(allowed_blocks) or []) + return text.__hash__() + allowed_tuple.__hash__() + collect_raw_data.__hash__() + + def extract_toplevel_blocks( text: str, allowed_blocks: Optional[Set[str]] = None, @@ -537,7 +557,7 @@ def extract_toplevel_blocks( Includes some special handling for block nesting. - :param data: The data to extract blocks from. + :param text: The data to extract blocks from. :param allowed_blocks: The names of the blocks to extract from the file. They may not be nested within if/for blocks. If None, use the default values. @@ -548,7 +568,19 @@ def extract_toplevel_blocks( :return: A list of `BlockTag`s matching the allowed block types and (if `collect_raw_data` is `True`) `BlockData` objects. """ + + if test_caching_enabled(): + hash = _get_blocks_hash(text, allowed_blocks, collect_raw_data) + if hash in _TESTING_BLOCKS_CACHE: + return _TESTING_BLOCKS_CACHE[hash] + tag_iterator = TagIterator(text) - return BlockIterator(tag_iterator).lex_for_blocks( + blocks = BlockIterator(tag_iterator).lex_for_blocks( allowed_blocks=allowed_blocks, collect_raw_data=collect_raw_data ) + + if test_caching_enabled(): + hash = _get_blocks_hash(text, allowed_blocks, collect_raw_data) + _TESTING_BLOCKS_CACHE[hash] = blocks + + return blocks diff --git a/dbt_common/context.py b/dbt_common/context.py new file mode 100644 index 00000000..07434432 --- /dev/null +++ b/dbt_common/context.py @@ -0,0 +1,35 @@ +from contextvars import ContextVar +from typing import List, Mapping, Optional + +from dbt_common.constants import SECRET_ENV_PREFIX + + +class InvocationContext: + def __init__(self, env: Mapping[str, str]): + self._env = env + self._env_secrets: Optional[List[str]] = None + # This class will also eventually manage the invocation_id, flags, event manager, etc. + + @property + def env(self) -> Mapping[str, str]: + return self._env + + @property + def env_secrets(self) -> List[str]: + if self._env_secrets is None: + self._env_secrets = [ + v for k, v in self.env.items() if k.startswith(SECRET_ENV_PREFIX) and v.strip() + ] + return self._env_secrets + + +_INVOCATION_CONTEXT_VAR: ContextVar[InvocationContext] = ContextVar("DBT_INVOCATION_CONTEXT_VAR") + + +def set_invocation_context(env: Mapping[str, str]) -> None: + _INVOCATION_CONTEXT_VAR.set(InvocationContext(env)) + + +def get_invocation_context() -> InvocationContext: + ctx = _INVOCATION_CONTEXT_VAR.get() + return ctx diff --git a/dbt_common/contracts/util.py b/dbt_common/contracts/util.py index 1467e4d8..7ec02463 100644 --- a/dbt_common/contracts/util.py +++ b/dbt_common/contracts/util.py @@ -5,3 +5,19 @@ class Replaceable: def replace(self, **kwargs): return dataclasses.replace(self, **kwargs) + + +class Mergeable(Replaceable): + def merged(self, *args): + """Perform a shallow merge, where the last non-None write wins. This is + intended to merge dataclasses that are a collection of optional values. + """ + replacements = {} + cls = type(self) + for arg in args: + for field in dataclasses.fields(cls): + value = getattr(arg, field.name) + if value is not None: + replacements[field.name] = value + + return self.replace(**replacements) diff --git a/dbt_common/tests.py b/dbt_common/tests.py new file mode 100644 index 00000000..bd8b747a --- /dev/null +++ b/dbt_common/tests.py @@ -0,0 +1,15 @@ +_TEST_CACHING_ENABLED: bool = False + + +def test_caching_enabled() -> bool: + return _TEST_CACHING_ENABLED + + +def enable_test_caching() -> None: + global _TEST_CACHING_ENABLED + _TEST_CACHING_ENABLED = True + + +def disable_test_caching() -> None: + global _TEST_CACHING_ENABLED + _TEST_CACHING_ENABLED = False diff --git a/tests/unit/test_contracts_util.py b/tests/unit/test_contracts_util.py new file mode 100644 index 00000000..2a620370 --- /dev/null +++ b/tests/unit/test_contracts_util.py @@ -0,0 +1,26 @@ +import unittest + +from dataclasses import dataclass +from dbt_common.contracts.util import Mergeable +from typing import List, Optional + + +@dataclass +class ExampleMergableClass(Mergeable): + attr_a: str + attr_b: Optional[int] + attr_c: Optional[List[str]] + + +class TestMergableClass(unittest.TestCase): + def test_mergeability(self): + mergeable1 = ExampleMergableClass( + attr_a="loses", attr_b=None, attr_c=["I'll", "still", "exist"] + ) + mergeable2 = ExampleMergableClass(attr_a="Wins", attr_b=1, attr_c=None) + merge_result: ExampleMergableClass = mergeable1.merged(mergeable2) + assert ( + merge_result.attr_a == mergeable2.attr_a + ) # mergeable2's attr_a is the "last" non None value + assert merge_result.attr_b == mergeable2.attr_b # mergeable1's attrb_b value was None + assert merge_result.attr_c == mergeable1.attr_c # mergeable2's attr_c value was None diff --git a/tests/unit/test_invocation_context.py b/tests/unit/test_invocation_context.py new file mode 100644 index 00000000..fa63cbc4 --- /dev/null +++ b/tests/unit/test_invocation_context.py @@ -0,0 +1,19 @@ +from dbt_common.constants import SECRET_ENV_PREFIX +from dbt_common.context import InvocationContext + + +def test_invocation_context_env(): + test_env = {"VAR_1": "value1", "VAR_2": "value2"} + ic = InvocationContext(env=test_env) + assert ic.env == test_env + + +def test_invocation_context_secrets(): + test_env = { + f"{SECRET_ENV_PREFIX}_VAR_1": "secret1", + f"{SECRET_ENV_PREFIX}VAR_2": "secret2", + "NON_SECRET": "non-secret", + f"foo{SECRET_ENV_PREFIX}": "non-secret", + } + ic = InvocationContext(env=test_env) + assert set(ic.env_secrets) == set(["secret1", "secret2"])