3. 自定义 Expectation 开发环境搭建:项目结构、模板代码、测试框架

说实话,很多人在 Great Expectations 里卡住,不是因为概念不懂,而是因为不知道怎么开始写第一个自定义 Expectation。我见过不少团队,文档翻了好几遍,最后还是在项目结构上栽了跟头。

这一章,我们就来把这个「开始」理清楚。我会带你搭一个干净、可复用的开发环境。你跟着走一遍,后面写插件就会顺手很多。

3.1 项目结构:别乱放,按规矩来

Great Expectations 对自定义插件的加载路径有严格要求。说白了,它只会在几个固定的目录里找你的代码。放错了,它就当没看见。

我个人习惯用这样的结构:

custom_expectations/
├── great_expectations.yml          # 项目级配置
├── expectations/
│   ├── __init__.py
│   ├── expect_column_values_to_be_positive.py
│   └── expect_column_values_to_be_in_range.py
├── plugins/
│   └── custom_metrics.py           # 可选:自定义 Metric
├── tests/
│   ├── __init__.py
│   ├── test_expect_column_values_to_be_positive.py
│   └── conftest.py                 # 测试夹具
├── data/
│   └── sample.csv                  # 测试用数据
└── requirements-dev.txt

这里有几个关键点:

  • expectations/ 目录是核心。每个文件放一个 Expectation 类。文件名和类名最好对应,方便查找。
  • plugins/ 目录放辅助逻辑。比如你写了一个复杂的统计函数,不想塞进 Expectation 类里,就放这里。
  • tests/ 目录必须和 expectations/ 保持镜像。我见过有人把所有测试写在一个文件里,结果项目大了根本跑不动。
小技巧: 在 great_expectations.yml 里加上 plugins_directory: plugins/,这样 Great Expectations 启动时会自动加载 plugins 目录下的模块。我曾经忘了配这个,调试了半小时才发现。

3.2 模板代码:从官方脚手架开始

写自定义 Expectation 最怕从零开始。好在 Great Expectations 官方提供了一个 CLI 命令,能帮你生成模板。

运行这个:

great_expectations expectation new

它会问你几个问题:Expectation 类型、列名、参数等。然后生成一个完整的 .py 文件。你打开看看,结构是这样的:

from great_expectations.expectations.expectation import ColumnMapExpectation
from great_expectations.expectations.util import render_evaluation_parameter_string

class ExpectColumnValuesToBePositive(ColumnMapExpectation):
    # 1. 元数据
    library_metadata = {
        "maturity": "experimental",
        "tags": ["custom", "numeric"],
        "contributors": ["your_name"],
    }

    # 2. 默认参数
    default_kwarg_values = {
        "mostly": 1.0,
        "result_format": "BASIC",
    }

    # 3. 核心逻辑
    def _validate(
        self,
        configuration: ExpectationConfiguration,
        metrics: dict,
        runtime_configuration: dict,
        execution_environment: ExecutionEnvironment,
    ):
        # 这里写你的验证逻辑
        pass

    # 4. 指标映射
    metric_dependencies = ["column_values.nonnull.count"]

    # 5. 成功条件
    success_keys = ("mostly",)

嗯,这里要注意:_validate 方法是你的主战场。所有逻辑都写在这里。它接收一个 metrics 字典,里面是 Great Expectations 计算好的统计值。你只需要判断这些值是否满足条件,然后返回一个字典。

返回格式必须是这样:

{
    "success": True/False,
    "result": {
        "observed_value": 0.95,
        "details": {...}
    }
}

为什么 success 和 result 要分开?因为 Great Expectations 的渲染引擎会根据 result 里的内容生成漂亮的 HTML 报告。你想想看,如果只返回一个布尔值,报告里就啥也看不到了。

3.3 测试框架:别等集成测试才发现问题

我刚开始写自定义 Expectation 时,习惯直接跑 great_expectations suite new 来验证。结果每次都要等几分钟,特别烦。后来我学乖了,用 pytest 写单元测试,几秒钟就能跑一轮。

测试框架的核心是 conftest.py。它负责创建测试用的 DataContext 和 DataFrame。这是我的标准模板:

# tests/conftest.py
import pytest
import pandas as pd
from great_expectations.data_context import BaseDataContext
from great_expectations.data_context.types.base import DataContextConfig, InMemoryStoreBackendDefaults

@pytest.fixture
def in_memory_context():
    """创建一个内存中的 DataContext,不依赖文件系统"""
    project_config = DataContextConfig(
        store_backend_defaults=InMemoryStoreBackendDefaults()
    )
    return BaseDataContext(project_config)

@pytest.fixture
def sample_df():
    """生成测试用的 DataFrame"""
    return pd.DataFrame({
        "values": [1, 2, 3, -1, 5, 0, 10],
        "names": ["a", "b", "c", "d", "e", "f", "g"],
    })

然后测试文件里这样写:

# tests/test_expect_column_values_to_be_positive.py
import pandas as pd
from expectations.expect_column_values_to_be_positive import ExpectColumnValuesToBePositive

class TestExpectColumnValuesToBePositive:
    def test_positive_values(self, in_memory_context, sample_df):
        # 只取正数部分
        df = sample_df[sample_df["values"] > 0]
        result = ExpectColumnValuesToBePositive().validate(
            df,
            result_format="COMPLETE"
        )
        assert result["success"] is True

    def test_negative_values(self, in_memory_context, sample_df):
        # 包含负数
        result = ExpectColumnValuesToBePositive().validate(
            sample_df,
            result_format="COMPLETE"
        )
        assert result["success"] is False
        assert result["result"]["observed_value"] == 4/7  # 4个正数 / 7个总数
避坑指南: 我曾经在测试里直接传 DataFrame,忘了指定 result_format。结果 Great Expectations 默认只返回 BASIC 格式,observed_value 是空的。后来我查了半天文档才发现。所以测试时一定要显式指定 result_format="COMPLETE"。

3.4 知识体系总览

下面这张图把整个开发环境的核心关系画出来了。你看一眼,心里就有数了。

自定义 Expectation 开发环境核心结构 项目结构 expectations/ plugins/ tests/ 模板代码 CLI 生成 + 手动调整 测试框架 pytest + conftest.py 项目结构细节 • expectations/__init__.py • plugins/custom_metrics.py • tests/conftest.py • data/sample.csv 模板代码关键方法 • _validate() 核心逻辑 • metric_dependencies • success_keys • default_kwarg_values 测试框架要点 • 内存 DataContext 夹具 • 显式指定 result_format • 测试正例 + 反例 • 验证 observed_value 开发流程建议 1. 用 CLI 生成模板 → 2. 修改 _validate 逻辑 → 3. 写单元测试 4. 本地跑 pytest 验证 → 5. 集成到 Great Expectations 项目 6. 用 result_format="COMPLETE" 调试 → 7. 提交代码

3.5 常见问题与避坑

问题 原因 解决方案
Great Expectations 找不到自定义 Expectation 插件目录未注册或路径不对 检查 great_expectations.yml 中的 plugins_directory 配置
测试中 observed_value 为空 未指定 result_format 或使用了 BASIC 模式 测试时显式传入 result_format="COMPLETE"
_validate 方法返回格式错误 缺少 success 或 result 字段 严格按照 {success: bool, result: dict} 格式返回
pytest 报模块找不到 PYTHONPATH 未包含项目根目录 在 conftest.py 中 sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

核心要点: 开发环境搭建的本质是「约定大于配置」。Great Expectations 对目录结构、类命名、方法签名都有约定。你遵守这些约定,它就能自动发现你的插件。你打破约定,它就会让你花大量时间调试。所以,从模板开始,不要自己造轮子。