diff --git a/docs/resources/authentication_policy.md b/docs/resources/authentication_policy.md index 0411027..22198d5 100644 --- a/docs/resources/authentication_policy.md +++ b/docs/resources/authentication_policy.md @@ -12,6 +12,26 @@ Defines the rules and constraints for authentication within the system, ensuring ## Examples +### Python + +```python +authentication_policy = AuthenticationPolicy( + name="some_authentication_policy", + authentication_methods=["PASSWORD", "SAML", "PROGRAMMATIC_ACCESS_TOKEN"], + mfa_authentication_methods=["PASSWORD"], + mfa_enrollment="REQUIRED", + client_types=["SNOWFLAKE_UI"], + security_integrations=["ALL"], + pat_policy={ + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + }, + comment="Policy for secure authentication." +) +``` + + ### YAML ```yaml @@ -20,6 +40,7 @@ authentication_policies: authentication_methods: - PASSWORD - SAML + - PROGRAMMATIC_ACCESS_TOKEN mfa_authentication_methods: - PASSWORD mfa_enrollment: REQUIRED @@ -27,25 +48,14 @@ authentication_policies: - SNOWFLAKE_UI security_integrations: - ALL + pat_policy: + network_policy_evaluation: ENFORCED_NOT_REQUIRED + default_expiry_in_days: 30 + max_expiry_in_days: 180 comment: Policy for secure authentication. ``` -### Python - -```python -authentication_policy = AuthenticationPolicy( - name="some_authentication_policy", - authentication_methods=["PASSWORD", "SAML"], - mfa_authentication_methods=["PASSWORD"], - mfa_enrollment="REQUIRED", - client_types=["SNOWFLAKE_UI"], - security_integrations=["ALL"], - comment="Policy for secure authentication." -) -``` - - ## Fields * `name` (string, required) - The name of the authentication policy. @@ -54,6 +64,7 @@ authentication_policy = AuthenticationPolicy( * `mfa_enrollment` (string) - Determines whether a user must enroll in multi-factor authentication. Defaults to OPTIONAL. * `client_types` (list) - A list of clients that can authenticate with Snowflake. * `security_integrations` (list) - A list of security integrations the authentication policy is associated with. +* `pat_policy` (dict) - Controls programmatic access token issuance: network_policy_evaluation, default_expiry_in_days, and max_expiry_in_days must all be given or all omitted; declaring exactly the Snowflake defaults (ENFORCED_REQUIRED, 15, 365) compares as unset. * `comment` (string) - A comment or description for the authentication policy. * `owner` (string or [Role](role.md)) - The owner role of the authentication policy. Defaults to SECURITYADMIN. diff --git a/snowcap/data_provider.py b/snowcap/data_provider.py index f556b0e..b1ee2c5 100644 --- a/snowcap/data_provider.py +++ b/snowcap/data_provider.py @@ -1384,6 +1384,15 @@ def fetch_authentication_policy(session: SnowflakeConnection, fqn: FQN): if mfa_auth_methods == ["PASSWORD"]: mfa_auth_methods = None + # pat_policy's brace shape is doc-derived and unverified against a live account (see + # _suppress_default_pat_policy above), so a parse failure must degrade to None instead of + # aborting the whole account-wide fetch pipeline (blueprint.py re-raises fetch exceptions). + try: + pat_policy = _suppress_default_pat_policy(_parse_pat_policy_property(properties.get("pat_policy"))) + except Exception as err: + logger.warning(f"Failed to parse pat_policy property {properties.get('pat_policy')!r}: {err}") + pat_policy = None + return { "name": _quote_snowflake_identifier(data["name"]), "authentication_methods": _parse_list_property(properties["authentication_methods"]), @@ -1391,6 +1400,7 @@ def fetch_authentication_policy(session: SnowflakeConnection, fqn: FQN): "mfa_enrollment": properties["mfa_enrollment"], "client_types": _parse_list_property(properties["client_types"]), "security_integrations": _parse_list_property(properties["security_integrations"]), + "pat_policy": pat_policy, "comment": data["comment"] or None, "owner": _get_owner_identifier(data), } @@ -1487,6 +1497,43 @@ def _parse_enum_map(value): return out +# Snowflake's documented AUTHENTICATION POLICY pat_policy defaults. This constant is the single +# source of truth for the fetch layer's pat_policy key schema (used to filter out sub-keys like +# REQUIRE_ROLE_RESTRICTION_FOR_SERVICE_USERS) and doubles as the suppression sentinel below. +_PAT_POLICY_DEFAULT = { + "network_policy_evaluation": "ENFORCED_REQUIRED", + "default_expiry_in_days": 15, + "max_expiry_in_days": 365, +} + + +def _parse_pat_policy_property(prop_value: Optional[str]) -> Optional[dict]: + # Contract: returns a COMPLETE dict (all _PAT_POLICY_DEFAULT keys), None (absent/empty/null + # input), or raises. A partial dict must never escape — __post_init__ on the resource dataclass + # enforces "None or complete" and blueprint.py re-raises any exception, aborting the + # account-wide fetch for every resource, so malformed input has to raise here and be caught by + # the fetch layer's except -> logger.warning -> None fallback. + if prop_value is None or prop_value == "" or prop_value == "null": + return None + parsed = _parse_enum_map(prop_value) + if not isinstance(parsed, dict): + raise ValueError(f"Unexpected pat_policy format: {prop_value!r}") + pat_policy = {key: parsed[key] for key in _PAT_POLICY_DEFAULT if key in parsed} + if pat_policy.keys() != _PAT_POLICY_DEFAULT.keys(): + raise ValueError(f"Incomplete pat_policy fields: {prop_value!r}") + pat_policy["default_expiry_in_days"] = int(pat_policy["default_expiry_in_days"]) + pat_policy["max_expiry_in_days"] = int(pat_policy["max_expiry_in_days"]) + return pat_policy + + +def _suppress_default_pat_policy(pat_policy: Optional[dict]) -> Optional[dict]: + # pat_policy is doc-derived (unverified against a live DESC AUTHENTICATION POLICY output), + # mirroring the mfa_authentication_methods drift-avoidance above: Snowflake echoes this + # default even when pat_policy was never explicitly set, so treat it as "unset" to avoid + # false drift detection. + return None if pat_policy == _PAT_POLICY_DEFAULT else pat_policy + + def fetch_columns(session: SnowflakeConnection, resource_type: str, fqn: FQN): desc_result = execute(session, f"DESC {resource_type} {fqn}") columns = [] diff --git a/snowcap/resources/authentication_policy.py b/snowcap/resources/authentication_policy.py index 9f14146..0792c6b 100644 --- a/snowcap/resources/authentication_policy.py +++ b/snowcap/resources/authentication_policy.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from ..enums import ParseableEnum, ResourceType -from ..props import EnumProp, Props, StringListProp, StringProp +from ..props import EnumProp, IntProp, Props, PropSet, StringListProp, StringProp from ..resource_name import ResourceName from ..role_ref import RoleRef from ..scope import SchemaScope @@ -14,6 +14,7 @@ class AuthenticationMethods(ParseableEnum): SAML = "SAML" OAUTH = "OAUTH" KEYPAIR = "KEYPAIR" + PROGRAMMATIC_ACCESS_TOKEN = "PROGRAMMATIC_ACCESS_TOKEN" class MFAEnrollment(ParseableEnum): @@ -28,6 +29,21 @@ class ClientTypes(ParseableEnum): SNOWSQL = "SNOWSQL" +class NetworkPolicyEvaluation(ParseableEnum): + ENFORCED_REQUIRED = "ENFORCED_REQUIRED" + ENFORCED_NOT_REQUIRED = "ENFORCED_NOT_REQUIRED" + NOT_ENFORCED = "NOT_ENFORCED" + + +# Single source of truth for the PAT_POLICY sub-key schema, shared by __post_init__ +# (to derive the required-key set) and AuthenticationPolicy.props (to render/parse it). +_PAT_POLICY_PROPS = Props( + network_policy_evaluation=EnumProp("NETWORK_POLICY_EVALUATION", NetworkPolicyEvaluation), + default_expiry_in_days=IntProp("default_expiry_in_days"), + max_expiry_in_days=IntProp("max_expiry_in_days"), +) + + @dataclass(unsafe_hash=True) class _AuthenticationPolicy(ResourceSpec): name: ResourceName @@ -36,6 +52,7 @@ class _AuthenticationPolicy(ResourceSpec): mfa_enrollment: MFAEnrollment = "OPTIONAL" client_types: list[ClientTypes] = None security_integrations: list[str] = None + pat_policy: dict = None comment: str = None owner: RoleRef = "SECURITYADMIN" @@ -68,6 +85,18 @@ def __post_init__(self): if self.security_integrations is None: self.security_integrations = ["ALL"] + if self.pat_policy is not None: + self.pat_policy = {k: v for k, v in self.pat_policy.items() if k in _PAT_POLICY_PROPS.props} + required_keys = set(_PAT_POLICY_PROPS.props) + missing_keys = required_keys - self.pat_policy.keys() + if missing_keys: + raise ValueError(f"pat_policy is missing required keys: {sorted(missing_keys)}") + + for key, prop in _PAT_POLICY_PROPS.props.items(): + self.pat_policy[key] = prop.typecheck(self.pat_policy[key]) + if self.pat_policy["default_expiry_in_days"] > self.pat_policy["max_expiry_in_days"]: + raise ValueError("pat_policy.default_expiry_in_days cannot exceed pat_policy.max_expiry_in_days") + class AuthenticationPolicy(NamedResource, Resource): """ @@ -84,6 +113,7 @@ class AuthenticationPolicy(NamedResource, Resource): mfa_enrollment (string): Determines whether a user must enroll in multi-factor authentication. Defaults to OPTIONAL. client_types (list): A list of clients that can authenticate with Snowflake. security_integrations (list): A list of security integrations the authentication policy is associated with. + pat_policy (dict): Controls programmatic access token issuance: network_policy_evaluation, default_expiry_in_days, and max_expiry_in_days must all be given or all omitted; declaring exactly the Snowflake defaults (ENFORCED_REQUIRED, 15, 365) compares as unset. comment (string): A comment or description for the authentication policy. owner (string or Role): The owner role of the authentication policy. Defaults to SECURITYADMIN. @@ -92,11 +122,16 @@ class AuthenticationPolicy(NamedResource, Resource): ```python authentication_policy = AuthenticationPolicy( name="some_authentication_policy", - authentication_methods=["PASSWORD", "SAML"], + authentication_methods=["PASSWORD", "SAML", "PROGRAMMATIC_ACCESS_TOKEN"], mfa_authentication_methods=["PASSWORD"], mfa_enrollment="REQUIRED", client_types=["SNOWFLAKE_UI"], security_integrations=["ALL"], + pat_policy={ + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + }, comment="Policy for secure authentication." ) ``` @@ -109,6 +144,7 @@ class AuthenticationPolicy(NamedResource, Resource): authentication_methods: - PASSWORD - SAML + - PROGRAMMATIC_ACCESS_TOKEN mfa_authentication_methods: - PASSWORD mfa_enrollment: REQUIRED @@ -116,6 +152,10 @@ class AuthenticationPolicy(NamedResource, Resource): - SNOWFLAKE_UI security_integrations: - ALL + pat_policy: + network_policy_evaluation: ENFORCED_NOT_REQUIRED + default_expiry_in_days: 30 + max_expiry_in_days: 180 comment: Policy for secure authentication. ``` """ @@ -127,6 +167,7 @@ class AuthenticationPolicy(NamedResource, Resource): mfa_enrollment=EnumProp("mfa_enrollment", MFAEnrollment), client_types=StringListProp("client_types", parens=True), security_integrations=StringListProp("security_integrations", parens=True), + pat_policy=PropSet("PAT_POLICY", _PAT_POLICY_PROPS), comment=StringProp("comment"), ) scope = SchemaScope() @@ -140,6 +181,7 @@ def __init__( mfa_enrollment: str = "OPTIONAL", client_types: list[str] = None, security_integrations: list[str] = None, + pat_policy: dict = None, comment: str = None, owner: str = "SECURITYADMIN", **kwargs, @@ -152,6 +194,7 @@ def __init__( mfa_enrollment=mfa_enrollment, client_types=client_types, security_integrations=security_integrations, + pat_policy=pat_policy, comment=comment, owner=owner, ) diff --git a/tests/fixtures/json/authentication_policy.json b/tests/fixtures/json/authentication_policy.json index 78a8518..4dcc4e8 100644 --- a/tests/fixtures/json/authentication_policy.json +++ b/tests/fixtures/json/authentication_policy.json @@ -11,6 +11,12 @@ "ALL" ], "authentication_methods": [ - "ALL" - ] + "PROGRAMMATIC_ACCESS_TOKEN", + "KEYPAIR" + ], + "pat_policy": { + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180 + } } diff --git a/tests/fixtures/sql/authentication_policy.sql b/tests/fixtures/sql/authentication_policy.sql index 8f4ba9a..d7f7964 100644 --- a/tests/fixtures/sql/authentication_policy.sql +++ b/tests/fixtures/sql/authentication_policy.sql @@ -1,2 +1,4 @@ CREATE AUTHENTICATION POLICY restrict_client_types_policy - CLIENT_TYPES = ('SNOWFLAKE_UI'); \ No newline at end of file + CLIENT_TYPES = ('SNOWFLAKE_UI') + AUTHENTICATION_METHODS = ('PROGRAMMATIC_ACCESS_TOKEN', 'KEYPAIR') + PAT_POLICY = (NETWORK_POLICY_EVALUATION = ENFORCED_NOT_REQUIRED DEFAULT_EXPIRY_IN_DAYS = 30 MAX_EXPIRY_IN_DAYS = 180); diff --git a/tests/integration/data_provider/test_fetch_resource_simple.py b/tests/integration/data_provider/test_fetch_resource_simple.py index 249da98..1bd5f83 100644 --- a/tests/integration/data_provider/test_fetch_resource_simple.py +++ b/tests/integration/data_provider/test_fetch_resource_simple.py @@ -46,6 +46,12 @@ def resource_fixtures() -> list: mfa_enrollment="REQUIRED", client_types=["SNOWFLAKE_UI"], security_integrations=["ALL"], + authentication_methods=["PROGRAMMATIC_ACCESS_TOKEN", "KEYPAIR"], + pat_policy={ + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + }, owner=TEST_ROLE, ), res.AzureStorageIntegration( diff --git a/tests/test_data_provider.py b/tests/test_data_provider.py index 2368656..d2adb9b 100644 --- a/tests/test_data_provider.py +++ b/tests/test_data_provider.py @@ -27,6 +27,8 @@ _parse_comma_separated_values, _parse_packages, _parse_storage_location, + _parse_pat_policy_property, + _suppress_default_pat_policy, _cast_param_value, params_result_to_dict, options_result_to_list, @@ -422,6 +424,89 @@ def test_trims_whitespace(self): assert result == ["item1", "item2"] +class TestParsePatPolicyProperty: + """Tests for _parse_pat_policy_property helper function.""" + + def test_parses_brace_map(self): + result = _parse_pat_policy_property( + "{NETWORK_POLICY_EVALUATION=ENFORCED_NOT_REQUIRED, DEFAULT_EXPIRY_IN_DAYS=30, MAX_EXPIRY_IN_DAYS=180}" + ) + assert result == { + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + } + + def test_drops_unknown_keys(self): + result = _parse_pat_policy_property( + "{NETWORK_POLICY_EVALUATION=ENFORCED_REQUIRED, DEFAULT_EXPIRY_IN_DAYS=15, " + "MAX_EXPIRY_IN_DAYS=365, REQUIRE_ROLE_RESTRICTION_FOR_SERVICE_USERS=true}" + ) + assert "require_role_restriction_for_service_users" not in result + assert result == { + "network_policy_evaluation": "ENFORCED_REQUIRED", + "default_expiry_in_days": 15, + "max_expiry_in_days": 365, + } + + def test_none_returns_none(self): + assert _parse_pat_policy_property(None) is None + + def test_empty_string_returns_none(self): + assert _parse_pat_policy_property("") is None + + def test_null_string_returns_none(self): + assert _parse_pat_policy_property("null") is None + + def test_whitespace_variants_parse_identically(self): + result = _parse_pat_policy_property( + "{ NETWORK_POLICY_EVALUATION = ENFORCED_NOT_REQUIRED, DEFAULT_EXPIRY_IN_DAYS = 30, MAX_EXPIRY_IN_DAYS = 180 }" + ) + assert result == { + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + } + + def test_no_braces_raises(self): + with pytest.raises(ValueError): + _parse_pat_policy_property("ENFORCED_REQUIRED") + + def test_unterminated_braces_raises(self): + with pytest.raises(ValueError): + _parse_pat_policy_property("{GARBAGE") + + def test_partial_keys_raises(self): + with pytest.raises(ValueError): + _parse_pat_policy_property("{DEFAULT_EXPIRY_IN_DAYS=30, MAX_EXPIRY_IN_DAYS=180}") + + +class TestSuppressDefaultPatPolicy: + """Tests for _suppress_default_pat_policy helper function.""" + + def test_suppresses_default(self): + result = _suppress_default_pat_policy( + { + "network_policy_evaluation": "ENFORCED_REQUIRED", + "default_expiry_in_days": 15, + "max_expiry_in_days": 365, + } + ) + assert result is None + + def test_passes_through_non_default(self): + pat_policy = { + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + } + result = _suppress_default_pat_policy(pat_policy) + assert result == pat_policy + + def test_none_returns_none(self): + assert _suppress_default_pat_policy(None) is None + + class TestParseSignature: """Tests for _parse_signature helper function.""" diff --git a/tests/test_resource_types.py b/tests/test_resource_types.py index 1d120a1..23ccf40 100644 --- a/tests/test_resource_types.py +++ b/tests/test_resource_types.py @@ -17,6 +17,7 @@ from snowcap.enums import ResourceType, WarehouseSize from snowcap.identifiers import FQN, URN from snowcap.resource_name import ResourceName +from snowcap.resources.authentication_policy import AuthenticationMethods, NetworkPolicyEvaluation class TestDatabase: @@ -995,6 +996,116 @@ def test_git_repository_with_credentials(self): assert repo._data.comment == "A private repo" +class TestAuthenticationPolicy: + """Tests for AuthenticationPolicy resource.""" + + def test_pat_authentication_method(self): + """Test PROGRAMMATIC_ACCESS_TOKEN is an accepted authentication method.""" + policy = res.AuthenticationPolicy( + name="test_policy", + authentication_methods=["PROGRAMMATIC_ACCESS_TOKEN", "KEYPAIR"], + ) + assert AuthenticationMethods.PROGRAMMATIC_ACCESS_TOKEN in policy._data.authentication_methods + + def test_network_policy_evaluation_enum(self): + """Test NetworkPolicyEvaluation parses valid values and rejects invalid ones.""" + assert NetworkPolicyEvaluation("ENFORCED_NOT_REQUIRED") == NetworkPolicyEvaluation.ENFORCED_NOT_REQUIRED + with pytest.raises(ValueError): + NetworkPolicyEvaluation("BOGUS") + + def test_pat_policy_renders_in_create_sql(self): + """Test create_sql renders the PAT_POLICY block with unquoted enum and int sub-props.""" + policy = res.AuthenticationPolicy( + name="test_policy", + pat_policy={ + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + }, + ) + sql = policy.create_sql() + assert "PAT_POLICY = (" in sql + assert "NETWORK_POLICY_EVALUATION = ENFORCED_NOT_REQUIRED" in sql + assert "DEFAULT_EXPIRY_IN_DAYS = 30" in sql + assert "MAX_EXPIRY_IN_DAYS = 180" in sql + + def test_pat_policy_absent_omits_clause(self): + """Test create_sql has no PAT_POLICY clause when pat_policy is unset.""" + policy = res.AuthenticationPolicy(name="test_policy") + assert "PAT_POLICY" not in policy.create_sql() + + def test_pat_policy_parses_from_sql_space_delimited(self): + """Test from_sql parses a space-delimited PAT_POLICY block.""" + sql = ( + "CREATE AUTHENTICATION POLICY p AUTHENTICATION_METHODS = ('PROGRAMMATIC_ACCESS_TOKEN', 'KEYPAIR') " + "PAT_POLICY = (NETWORK_POLICY_EVALUATION = ENFORCED_NOT_REQUIRED " + "DEFAULT_EXPIRY_IN_DAYS = 30 MAX_EXPIRY_IN_DAYS = 180)" + ) + policy = res.AuthenticationPolicy.from_sql(sql) + assert policy._data.pat_policy == { + "network_policy_evaluation": NetworkPolicyEvaluation.ENFORCED_NOT_REQUIRED, + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + } + + def test_pat_policy_to_dict_round_trips(self): + """Test to_dict serializes the enum to a plain string and round-trips through spec().""" + policy = res.AuthenticationPolicy( + name="test_policy", + pat_policy={ + "network_policy_evaluation": "ENFORCED_NOT_REQUIRED", + "default_expiry_in_days": 30, + "max_expiry_in_days": 180, + }, + ) + data = policy.to_dict() + assert data["pat_policy"]["network_policy_evaluation"] == "ENFORCED_NOT_REQUIRED" + assert res.AuthenticationPolicy.spec(**data) == policy._data + + def test_pat_policy_partial_raises(self): + """Test a partial pat_policy dict raises ValueError naming the missing keys.""" + with pytest.raises(ValueError): + res.AuthenticationPolicy(name="test_policy", pat_policy={"max_expiry_in_days": 180}) + + def test_pat_policy_drops_unknown_keys(self): + """Test unrecognized sub-keys (future Snowflake additions arriving via fetch) are dropped.""" + policy = res.AuthenticationPolicy( + name="test_policy", + pat_policy={ + "network_policy_evaluation": "ENFORCED_REQUIRED", + "default_expiry_in_days": 15, + "max_expiry_in_days": 365, + "require_role_restriction_for_service_users": True, + }, + ) + assert "require_role_restriction_for_service_users" not in policy._data.pat_policy + + def test_pat_policy_default_exceeds_max_raises(self): + """Test default_expiry_in_days greater than max_expiry_in_days raises ValueError.""" + with pytest.raises(ValueError): + res.AuthenticationPolicy( + name="test_policy", + pat_policy={ + "network_policy_evaluation": "ENFORCED_REQUIRED", + "default_expiry_in_days": 200, + "max_expiry_in_days": 100, + }, + ) + + def test_pat_policy_coerces_string_ints(self): + """Test string-valued expiry days coerce to ints.""" + policy = res.AuthenticationPolicy( + name="test_policy", + pat_policy={ + "network_policy_evaluation": "ENFORCED_REQUIRED", + "default_expiry_in_days": "30", + "max_expiry_in_days": "180", + }, + ) + assert policy._data.pat_policy["default_expiry_in_days"] == 30 + assert policy._data.pat_policy["max_expiry_in_days"] == 180 + + class TestPasswordPolicy: """Tests for PasswordPolicy resource.""" diff --git a/tools/generate_resource_docs.py b/tools/generate_resource_docs.py index 5bb706d..62ce849 100644 --- a/tools/generate_resource_docs.py +++ b/tools/generate_resource_docs.py @@ -10,12 +10,12 @@ doc_template = """\ --- description: >- - +{frontmatter_description} --- # {class_name} -[Snowflake Documentation]({snowflake_docs}) +[Snowflake Documentation]({snowflake_docs}) | Snowcap CLI label: `{resource_label}` {description} @@ -185,6 +185,28 @@ def camelcase_to_snakecase(name: str) -> str: return name +def default_frontmatter_description(resource_class_name: str) -> str: + # Every committed description that doesn't have bespoke wording follows + # this pattern, so it's a reasonable default for resources that don't + # have a hand-written one yet (see get_frontmatter_description). + human_name = camelcase_to_snakecase(resource_class_name).replace("_", " ") + article = "An" if human_name[0].lower() in "aeiou" else "A" + return f" {article} {human_name} in Snowflake." + + +def get_frontmatter_description(doc_file: str, resource_class_name: str) -> str: + # Some descriptions (e.g. grant.md, masking-policy.md) are bespoke and not + # derivable from the docstring, so preserve whatever is already committed. + # Only fall back to the generated pattern when there's nothing to preserve. + if os.path.exists(doc_file): + with open(doc_file, "r") as f: + content = f.read() + match = re.match(r"^---\ndescription: >-\n((?: .*\n)+)---\n", content) + if match and match.group(1).strip(): + return match.group(1).rstrip("\n") + return default_frontmatter_description(resource_class_name) + + def generate_resource_doc(resource_class_name: str, resource_docstring: str): doc_file_name = camelcase_to_snakecase(resource_class_name) @@ -197,7 +219,10 @@ def generate_resource_doc(resource_class_name: str, resource_docstring: str): # print(fields_md) # return - with open(os.path.join(DOCS_ROOT, "resources", f"{doc_file_name}.md"), "w") as f: + doc_file = os.path.join(DOCS_ROOT, "resources", f"{doc_file_name}.md") + frontmatter_description = get_frontmatter_description(doc_file, resource_class_name) + + with open(doc_file, "w") as f: f.write( doc_template.format( class_name=resource_class_name, @@ -206,6 +231,8 @@ def generate_resource_doc(resource_class_name: str, resource_docstring: str): fields=fields_md, python_example=parsed["Python"], yaml_example=parsed["Yaml"], + frontmatter_description=frontmatter_description, + resource_label=doc_file_name, ) )