feat: Add descriptive comments and examples to create-config output (#805)

This commit is contained in:
Jens
2026-02-02 17:20:56 +01:00
committed by GitHub
parent 3229656ef4
commit e85126ec86
9 changed files with 649 additions and 165 deletions

View File

@@ -22,7 +22,11 @@
} }
], ],
"default": null, "default": null,
"description": "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount", "description": "reduction strategy (required when enabled: true). PERCENTAGE = % of price, FIXED = absolute amount",
"examples": [
"PERCENTAGE",
"FIXED"
],
"title": "Strategy" "title": "Strategy"
}, },
"amount": { "amount": {
@@ -36,7 +40,12 @@
} }
], ],
"default": null, "default": null,
"description": "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED", "description": "reduction amount (required when enabled: true). For PERCENTAGE: use percent value (e.g., 10 = 10%%). For FIXED: use currency amount",
"examples": [
10.0,
5.0,
20.0
],
"title": "Amount" "title": "Amount"
}, },
"min_price": { "min_price": {
@@ -50,7 +59,12 @@
} }
], ],
"default": null, "default": null,
"description": "required when enabled is true; minimum price floor (use 0 for no lower bound)", "description": "minimum price floor (required when enabled: true). Use 0 for no minimum",
"examples": [
1.0,
5.0,
10.0
],
"title": "Min Price" "title": "Min Price"
}, },
"delay_reposts": { "delay_reposts": {

View File

@@ -4,15 +4,21 @@
"properties": { "properties": {
"active": { "active": {
"default": true, "default": true,
"description": "whether the ad should be published (false = skip this ad)",
"title": "Active", "title": "Active",
"type": "boolean" "type": "boolean"
}, },
"type": { "type": {
"default": "OFFER", "default": "OFFER",
"description": "type of the ad listing",
"enum": [ "enum": [
"OFFER", "OFFER",
"WANTED" "WANTED"
], ],
"examples": [
"OFFER",
"WANTED"
],
"title": "Type", "title": "Type",
"type": "string" "type": "string"
}, },
@@ -25,7 +31,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null "default": null,
"description": "DEPRECATED: Use description_prefix/description_suffix instead"
}, },
"description_prefix": { "description_prefix": {
"anyOf": [ "anyOf": [
@@ -36,8 +43,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null, "default": "",
"description": "prefix for the ad description", "description": "text to prepend to each ad (optional)",
"title": "Description Prefix" "title": "Description Prefix"
}, },
"description_suffix": { "description_suffix": {
@@ -49,38 +56,51 @@
"type": "null" "type": "null"
} }
], ],
"default": null, "default": "",
"description": "suffix for the ad description", "description": "text to append to each ad (optional)",
"title": "Description Suffix" "title": "Description Suffix"
}, },
"price_type": { "price_type": {
"default": "NEGOTIABLE", "default": "NEGOTIABLE",
"description": "pricing strategy for the listing",
"enum": [ "enum": [
"FIXED", "FIXED",
"NEGOTIABLE", "NEGOTIABLE",
"GIVE_AWAY", "GIVE_AWAY",
"NOT_APPLICABLE" "NOT_APPLICABLE"
], ],
"examples": [
"FIXED",
"NEGOTIABLE",
"GIVE_AWAY",
"NOT_APPLICABLE"
],
"title": "Price Type", "title": "Price Type",
"type": "string" "type": "string"
}, },
"auto_price_reduction": { "auto_price_reduction": {
"$ref": "#/$defs/AutoPriceReductionConfig", "$ref": "#/$defs/AutoPriceReductionConfig",
"description": "automatic price reduction configuration" "description": "automatic price reduction configuration for reposted ads"
}, },
"shipping_type": { "shipping_type": {
"default": "SHIPPING", "default": "SHIPPING",
"description": "shipping method for the item",
"enum": [ "enum": [
"PICKUP", "PICKUP",
"SHIPPING", "SHIPPING",
"NOT_APPLICABLE" "NOT_APPLICABLE"
], ],
"examples": [
"PICKUP",
"SHIPPING",
"NOT_APPLICABLE"
],
"title": "Shipping Type", "title": "Shipping Type",
"type": "string" "type": "string"
}, },
"sell_directly": { "sell_directly": {
"default": false, "default": false,
"description": "requires shipping_type SHIPPING to take effect", "description": "enable direct purchase option (only works when shipping_type is SHIPPING)",
"title": "Sell Directly", "title": "Sell Directly",
"type": "boolean" "type": "boolean"
}, },
@@ -96,14 +116,20 @@
"type": "null" "type": "null"
} }
], ],
"default": null, "description": "default image glob patterns (optional). Leave empty for no default images",
"examples": [
"\"images/*.jpg\"",
"\"photos/*.{png,jpg}\""
],
"title": "Images" "title": "Images"
}, },
"contact": { "contact": {
"$ref": "#/$defs/ContactDefaults" "$ref": "#/$defs/ContactDefaults",
"description": "default contact information for ads"
}, },
"republication_interval": { "republication_interval": {
"default": 7, "default": 7,
"description": "number of days between automatic republication of ads",
"title": "Republication Interval", "title": "Republication Interval",
"type": "integer" "type": "integer"
} }
@@ -133,7 +159,11 @@
} }
], ],
"default": null, "default": null,
"description": "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount", "description": "reduction strategy (required when enabled: true). PERCENTAGE = % of price, FIXED = absolute amount",
"examples": [
"PERCENTAGE",
"FIXED"
],
"title": "Strategy" "title": "Strategy"
}, },
"amount": { "amount": {
@@ -147,7 +177,12 @@
} }
], ],
"default": null, "default": null,
"description": "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED", "description": "reduction amount (required when enabled: true). For PERCENTAGE: use percent value (e.g., 10 = 10%%). For FIXED: use currency amount",
"examples": [
10.0,
5.0,
20.0
],
"title": "Amount" "title": "Amount"
}, },
"min_price": { "min_price": {
@@ -161,7 +196,12 @@
} }
], ],
"default": null, "default": null,
"description": "required when enabled is true; minimum price floor (use 0 for no lower bound)", "description": "minimum price floor (required when enabled: true). Use 0 for no minimum",
"examples": [
1.0,
5.0,
10.0
],
"title": "Min Price" "title": "Min Price"
}, },
"delay_reposts": { "delay_reposts": {
@@ -185,7 +225,12 @@
"BrowserConfig": { "BrowserConfig": {
"properties": { "properties": {
"arguments": { "arguments": {
"description": "See https://peter.sh/experiments/chromium-command-line-switches/. Browser profile path is auto-configured based on installation mode (portable/XDG).", "description": "additional Chromium command line switches (optional). Leave as [] for default behavior. See https://peter.sh/experiments/chromium-command-line-switches/ Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path",
"examples": [
"\"--headless\"",
"\"--disable-dev-shm-usage\"",
"\"--user-data-dir=/path/to/profile\""
],
"items": { "items": {
"type": "string" "type": "string"
}, },
@@ -201,12 +246,16 @@
"type": "null" "type": "null"
} }
], ],
"default": null, "default": "",
"description": "path to custom browser executable, if not specified will be looked up on PATH", "description": "path to custom browser executable (optional). Leave empty to use system default",
"title": "Binary Location" "title": "Binary Location"
}, },
"extensions": { "extensions": {
"description": "a list of .crx extension files to be loaded", "description": "Chrome extensions to load (optional). Leave as [] for no extensions. Add .crx file paths relative to config file",
"examples": [
"\"extensions/adblock.crx\"",
"\"/absolute/path/to/extension.crx\""
],
"items": { "items": {
"type": "string" "type": "string"
}, },
@@ -215,6 +264,7 @@
}, },
"use_private_window": { "use_private_window": {
"default": true, "default": true,
"description": "open browser in private/incognito mode (recommended to avoid cookie conflicts)",
"title": "Use Private Window", "title": "Use Private Window",
"type": "boolean" "type": "boolean"
}, },
@@ -227,8 +277,8 @@
"type": "null" "type": "null"
} }
], ],
"default": null, "default": "",
"description": "See https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md. If not specified, defaults to XDG cache directory in XDG mode or .temp/browser-profile in portable mode.", "description": "custom browser profile directory (optional). Leave empty for auto-configured default",
"title": "User Data Dir" "title": "User Data Dir"
}, },
"profile_name": { "profile_name": {
@@ -240,7 +290,11 @@
"type": "null" "type": "null"
} }
], ],
"default": null, "default": "",
"description": "browser profile name (optional). Leave empty for default profile",
"examples": [
"\"Profile 1\""
],
"title": "Profile Name" "title": "Profile Name"
} }
}, },
@@ -251,11 +305,18 @@
"properties": { "properties": {
"auto_restart": { "auto_restart": {
"default": false, "default": false,
"description": "if true, abort when captcha is detected and auto-retry after restart_delay (if false, wait for manual solving)",
"title": "Auto Restart", "title": "Auto Restart",
"type": "boolean" "type": "boolean"
}, },
"restart_delay": { "restart_delay": {
"default": "6h", "default": "6h",
"description": "duration to wait before retrying after captcha detection (e.g., 1h30m, 6h, 30m)",
"examples": [
"6h",
"1h30m",
"30m"
],
"title": "Restart Delay", "title": "Restart Delay",
"type": "string" "type": "string"
} }
@@ -285,28 +346,16 @@
"ContactDefaults": { "ContactDefaults": {
"properties": { "properties": {
"name": { "name": {
"anyOf": [ "default": "",
{ "description": "contact name displayed on the ad",
"type": "string" "title": "Name",
}, "type": "string"
{
"type": "null"
}
],
"default": null,
"title": "Name"
}, },
"street": { "street": {
"anyOf": [ "default": "",
{ "description": "street address for the listing",
"type": "string" "title": "Street",
}, "type": "string"
{
"type": "null"
}
],
"default": null,
"title": "Street"
}, },
"zipcode": { "zipcode": {
"anyOf": [ "anyOf": [
@@ -315,41 +364,29 @@
}, },
{ {
"type": "string" "type": "string"
},
{
"type": "null"
} }
], ],
"default": null, "default": "",
"description": "postal/ZIP code for the listing location",
"title": "Zipcode" "title": "Zipcode"
}, },
"location": { "location": {
"anyOf": [ "default": "",
{
"type": "string"
},
{
"type": "null"
}
],
"default": null,
"description": "city or locality of the listing (can include multiple districts)", "description": "city or locality of the listing (can include multiple districts)",
"examples": [ "examples": [
"Sample Town - District One" "Sample Town - District One"
], ],
"title": "Location" "title": "Location",
"type": "string"
}, },
"phone": { "phone": {
"anyOf": [ "default": "",
{ "description": "phone number for contact - only available for commercial accounts, personal accounts no longer support this",
"type": "string" "examples": [
}, "\"01234 567890\""
{
"type": "null"
}
], ],
"default": null, "title": "Phone",
"title": "Phone" "type": "string"
} }
}, },
"title": "ContactDefaults", "title": "ContactDefaults",
@@ -368,6 +405,7 @@
} }
], ],
"default": null, "default": null,
"description": "text to prepend to the ad description (deprecated, use description_prefix)",
"title": "Prefix" "title": "Prefix"
}, },
"suffix": { "suffix": {
@@ -380,6 +418,7 @@
} }
], ],
"default": null, "default": null,
"description": "text to append to the ad description (deprecated, use description_suffix)",
"title": "Suffix" "title": "Suffix"
} }
}, },
@@ -430,7 +469,12 @@
"type": "boolean" "type": "boolean"
}, },
"excluded_shipping_options": { "excluded_shipping_options": {
"description": "list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']", "description": "shipping options to exclude (optional). Leave as [] to include all. Add items like 'DHL_2' to exclude specific carriers",
"examples": [
"\"DHL_2\"",
"\"DHL_5\"",
"\"Hermes\""
],
"items": { "items": {
"type": "string" "type": "string"
}, },
@@ -458,11 +502,13 @@
"LoginConfig": { "LoginConfig": {
"properties": { "properties": {
"username": { "username": {
"description": "kleinanzeigen.de login email or username",
"minLength": 1, "minLength": 1,
"title": "Username", "title": "Username",
"type": "string" "type": "string"
}, },
"password": { "password": {
"description": "kleinanzeigen.de login password",
"minLength": 1, "minLength": 1,
"title": "Password", "title": "Password",
"type": "string" "type": "string"
@@ -492,11 +538,17 @@
} }
], ],
"default": "AFTER_PUBLISH", "default": "AFTER_PUBLISH",
"description": "when to delete old versions of republished ads",
"examples": [
"BEFORE_PUBLISH",
"AFTER_PUBLISH",
"NEVER"
],
"title": "Delete Old Ads" "title": "Delete Old Ads"
}, },
"delete_old_ads_by_title": { "delete_old_ads_by_title": {
"default": true, "default": true,
"description": "only works if delete_old_ads is set to BEFORE_PUBLISH", "description": "match old ads by title when deleting (only works with BEFORE_PUBLISH)",
"title": "Delete Old Ads By Title", "title": "Delete Old Ads By Title",
"type": "boolean" "type": "boolean"
} }
@@ -657,24 +709,35 @@
"type": "object" "type": "object"
}, },
"UpdateCheckConfig": { "UpdateCheckConfig": {
"description": "Configuration for update checking functionality.\n\nAttributes:\n enabled: Whether update checking is enabled.\n channel: Which release channel to check ('latest' for stable, 'preview' for prereleases).\n interval: How often to check for updates (e.g. '7d', '1d').\n If the interval is invalid, too short (<1d), or too long (>30d),\n the bot will log a warning and use a default interval for this run:\n - 1d for 'preview' channel\n - 7d for 'latest' channel\n The config file is not changed automatically; please fix your config to avoid repeated warnings.",
"properties": { "properties": {
"enabled": { "enabled": {
"default": true, "default": true,
"description": "whether to check for updates on startup",
"title": "Enabled", "title": "Enabled",
"type": "boolean" "type": "boolean"
}, },
"channel": { "channel": {
"default": "latest", "default": "latest",
"description": "which release channel to check (latest = stable, preview = prereleases)",
"enum": [ "enum": [
"latest", "latest",
"preview" "preview"
], ],
"examples": [
"latest",
"preview"
],
"title": "Channel", "title": "Channel",
"type": "string" "type": "string"
}, },
"interval": { "interval": {
"default": "7d", "default": "7d",
"description": "how often to check for updates (e.g., 7d, 1d). If invalid, too short (<1d), or too long (>30d), uses defaults: 1d for 'preview' channel, 7d for 'latest' channel",
"examples": [
"7d",
"1d",
"14d"
],
"title": "Interval", "title": "Interval",
"type": "string" "type": "string"
} }
@@ -704,7 +767,11 @@
"additionalProperties": { "additionalProperties": {
"type": "string" "type": "string"
}, },
"description": "\nadditional name to category ID mappings, see default list at\nhttps://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml\n\nExample:\n categories:\n Elektronik > Notebooks: 161/278\n Jobs > Praktika: 102/125\n ", "description": "additional name to category ID mappings (optional). Leave as {} if not needed. See full list at: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml To add: use format 'Category > Subcategory': 'ID'",
"examples": [
"\"Elektronik > Notebooks\": \"161/278\"",
"\"Jobs > Praktika\": \"102/125\""
],
"title": "Categories", "title": "Categories",
"type": "object" "type": "object"
}, },
@@ -734,16 +801,8 @@
"description": "Centralized timeout configuration." "description": "Centralized timeout configuration."
}, },
"diagnostics": { "diagnostics": {
"anyOf": [ "$ref": "#/$defs/DiagnosticsConfig",
{ "description": "diagnostics capture configuration for troubleshooting"
"$ref": "#/$defs/DiagnosticsConfig"
},
{
"type": "null"
}
],
"default": null,
"description": "Optional failure-only diagnostics capture."
} }
}, },
"title": "Config", "title": "Config",

View File

@@ -579,14 +579,15 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
default_config = Config.model_construct() default_config = Config.model_construct()
default_config.login.username = "changeme" # noqa: S105 placeholder for default config, not a real username default_config.login.username = "changeme" # noqa: S105 placeholder for default config, not a real username
default_config.login.password = "changeme" # noqa: S105 placeholder for default config, not a real password default_config.login.password = "changeme" # noqa: S105 placeholder for default config, not a real password
dicts.save_dict( dicts.save_commented_model(
self.config_file_path, self.config_file_path,
default_config.model_dump(exclude_none = True, exclude = {"ad_defaults": {"description"}}), default_config,
header=( header=(
"# yaml-language-server: $schema=" "# yaml-language-server: $schema="
"https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot" "https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot"
"/refs/heads/main/schemas/config.schema.json" "/refs/heads/main/schemas/config.schema.json"
), ),
exclude = {"ad_defaults": {"description"}},
) )
def load_config(self) -> None: def load_config(self) -> None:

View File

@@ -23,12 +23,19 @@ _MAX_PERCENTAGE:Final[int] = 100
class AutoPriceReductionConfig(ContextualModel): class AutoPriceReductionConfig(ContextualModel):
enabled:bool = Field(default = False, description = "automatically lower the price of reposted ads") enabled:bool = Field(default = False, description = "automatically lower the price of reposted ads")
strategy:Literal["FIXED", "PERCENTAGE"] | None = Field( strategy:Literal["FIXED", "PERCENTAGE"] | None = Field(
default = None, description = "PERCENTAGE reduces by a percentage of the previous price, FIXED reduces by a fixed amount" default = None,
description = "reduction strategy (required when enabled: true). PERCENTAGE = % of price, FIXED = absolute amount",
examples = ["PERCENTAGE", "FIXED"],
) )
amount:float | None = Field( amount:float | None = Field(
default = None, gt = 0, description = "magnitude of the reduction; interpreted as percent for PERCENTAGE or currency units for FIXED" default = None,
gt = 0,
description = "reduction amount (required when enabled: true). For PERCENTAGE: use percent value (e.g., 10 = 10%%). For FIXED: use currency amount",
examples = [10.0, 5.0, 20.0],
)
min_price:float | None = Field(
default = None, ge = 0, description = "minimum price floor (required when enabled: true). Use 0 for no minimum", examples = [1.0, 5.0, 10.0]
) )
min_price:float | None = Field(default = None, ge = 0, description = "required when enabled is true; minimum price floor (use 0 for no lower bound)")
delay_reposts:int = Field(default = 0, ge = 0, description = "number of reposts to wait before applying the first automatic price reduction") delay_reposts:int = Field(default = 0, ge = 0, description = "number of reposts to wait before applying the first automatic price reduction")
delay_days:int = Field(default = 0, ge = 0, description = "number of days to wait after publication before applying automatic price reductions") delay_days:int = Field(default = 0, ge = 0, description = "number of days to wait after publication before applying automatic price reductions")
@@ -47,34 +54,50 @@ class AutoPriceReductionConfig(ContextualModel):
class ContactDefaults(ContextualModel): class ContactDefaults(ContextualModel):
name:str | None = None name:str = Field(default = "", description = "contact name displayed on the ad")
street:str | None = None street:str = Field(default = "", description = "street address for the listing")
zipcode:int | str | None = None zipcode:int | str = Field(default = "", description = "postal/ZIP code for the listing location")
location:str | None = Field( location:str = Field(
default = None, description = "city or locality of the listing (can include multiple districts)", examples = ["Sample Town - District One"] default = "",
description = "city or locality of the listing (can include multiple districts)",
examples = ["Sample Town - District One"],
)
phone:str = Field(
default = "",
description = "phone number for contact - only available for commercial accounts, personal accounts no longer support this",
examples = ['"01234 567890"'],
) )
phone:str | None = None
@deprecated("Use description_prefix/description_suffix instead") @deprecated("Use description_prefix/description_suffix instead")
class DescriptionAffixes(ContextualModel): class DescriptionAffixes(ContextualModel):
prefix:str | None = None prefix:str | None = Field(default = None, description = "text to prepend to the ad description (deprecated, use description_prefix)")
suffix:str | None = None suffix:str | None = Field(default = None, description = "text to append to the ad description (deprecated, use description_suffix)")
class AdDefaults(ContextualModel): class AdDefaults(ContextualModel):
active:bool = True active:bool = Field(default = True, description = "whether the ad should be published (false = skip this ad)")
type:Literal["OFFER", "WANTED"] = "OFFER" type:Literal["OFFER", "WANTED"] = Field(default = "OFFER", description = "type of the ad listing", examples = ["OFFER", "WANTED"])
description:DescriptionAffixes | None = None description:DescriptionAffixes | None = Field(default = None, description = "DEPRECATED: Use description_prefix/description_suffix instead")
description_prefix:str | None = Field(default = None, description = "prefix for the ad description") description_prefix:str | None = Field(default = "", description = "text to prepend to each ad (optional)")
description_suffix:str | None = Field(default = None, description = "suffix for the ad description") description_suffix:str | None = Field(default = "", description = "text to append to each ad (optional)")
price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE" price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = Field(
auto_price_reduction:AutoPriceReductionConfig = Field(default_factory = AutoPriceReductionConfig, description = "automatic price reduction configuration") default = "NEGOTIABLE", description = "pricing strategy for the listing", examples = ["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"]
shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING" )
sell_directly:bool = Field(default = False, description = "requires shipping_type SHIPPING to take effect") auto_price_reduction:AutoPriceReductionConfig = Field(
images:list[str] | None = Field(default = None) default_factory = AutoPriceReductionConfig, description = "automatic price reduction configuration for reposted ads"
contact:ContactDefaults = Field(default_factory = ContactDefaults) )
republication_interval:int = 7 shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = Field(
default = "SHIPPING", description = "shipping method for the item", examples = ["PICKUP", "SHIPPING", "NOT_APPLICABLE"]
)
sell_directly:bool = Field(default = False, description = "enable direct purchase option (only works when shipping_type is SHIPPING)")
images:list[str] | None = Field(
default_factory = list,
description = "default image glob patterns (optional). Leave empty for no default images",
examples = ['"images/*.jpg"', '"photos/*.{png,jpg}"'],
)
contact:ContactDefaults = Field(default_factory = ContactDefaults, description = "default contact information for ads")
republication_interval:int = Field(default = 7, description = "number of days between automatic republication of ads")
@model_validator(mode = "before") @model_validator(mode = "before")
@classmethod @classmethod
@@ -99,7 +122,8 @@ class DownloadConfig(ContextualModel):
) )
excluded_shipping_options:list[str] = Field( excluded_shipping_options:list[str] = Field(
default_factory = list, default_factory = list,
description = "list of shipping options to exclude, e.g. ['DHL_2', 'DHL_5']", description = ("shipping options to exclude (optional). Leave as [] to include all. Add items like 'DHL_2' to exclude specific carriers"),
examples = ['"DHL_2"', '"DHL_5"', '"Hermes"'],
) )
folder_name_max_length:int = Field( folder_name_max_length:int = Field(
default = 100, default = 100,
@@ -117,36 +141,49 @@ class BrowserConfig(ContextualModel):
arguments:list[str] = Field( arguments:list[str] = Field(
default_factory = list, default_factory = list,
description=( description=(
"See https://peter.sh/experiments/chromium-command-line-switches/. " "additional Chromium command line switches (optional). Leave as [] for default behavior. "
"Browser profile path is auto-configured based on installation mode (portable/XDG)." "See https://peter.sh/experiments/chromium-command-line-switches/ "
"Common: --headless (no GUI), --disable-dev-shm-usage (Docker fix), --user-data-dir=/path"
), ),
examples = ['"--headless"', '"--disable-dev-shm-usage"', '"--user-data-dir=/path/to/profile"'],
) )
binary_location:str | None = Field(default = None, description = "path to custom browser executable, if not specified will be looked up on PATH") binary_location:str | None = Field(default = "", description = "path to custom browser executable (optional). Leave empty to use system default")
extensions:list[str] = Field(default_factory = list, description = "a list of .crx extension files to be loaded") extensions:list[str] = Field(
use_private_window:bool = True default_factory = list,
description = "Chrome extensions to load (optional). Leave as [] for no extensions. Add .crx file paths relative to config file",
examples = ['"extensions/adblock.crx"', '"/absolute/path/to/extension.crx"'],
)
use_private_window:bool = Field(default = True, description = "open browser in private/incognito mode (recommended to avoid cookie conflicts)")
user_data_dir:str | None = Field( user_data_dir:str | None = Field(
default = None, default = "",
description=( description = "custom browser profile directory (optional). Leave empty for auto-configured default",
"See https://github.com/chromium/chromium/blob/main/docs/user_data_dir.md. " )
"If not specified, defaults to XDG cache directory in XDG mode or .temp/browser-profile in portable mode." profile_name:str | None = Field(
), default = "",
description = "browser profile name (optional). Leave empty for default profile",
examples = ['"Profile 1"'],
) )
profile_name:str | None = None
class LoginConfig(ContextualModel): class LoginConfig(ContextualModel):
username:str = Field(..., min_length = 1) username:str = Field(..., min_length = 1, description = "kleinanzeigen.de login email or username")
password:str = Field(..., min_length = 1) password:str = Field(..., min_length = 1, description = "kleinanzeigen.de login password")
class PublishingConfig(ContextualModel): class PublishingConfig(ContextualModel):
delete_old_ads:Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = "AFTER_PUBLISH" delete_old_ads:Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = Field(
delete_old_ads_by_title:bool = Field(default = True, description = "only works if delete_old_ads is set to BEFORE_PUBLISH") default = "AFTER_PUBLISH", description = "when to delete old versions of republished ads", examples = ["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"]
)
delete_old_ads_by_title:bool = Field(default = True, description = "match old ads by title when deleting (only works with BEFORE_PUBLISH)")
class CaptchaConfig(ContextualModel): class CaptchaConfig(ContextualModel):
auto_restart:bool = False auto_restart:bool = Field(
restart_delay:str = "6h" default = False, description = "if true, abort when captcha is detected and auto-retry after restart_delay (if false, wait for manual solving)"
)
restart_delay:str = Field(
default = "6h", description = "duration to wait before retrying after captcha detection (e.g., 1h30m, 6h, 30m)", examples = ["6h", "1h30m", "30m"]
)
class TimeoutConfig(ContextualModel): class TimeoutConfig(ContextualModel):
@@ -291,15 +328,12 @@ if relative paths are specified, then they are relative to this configuration fi
categories:dict[str, str] = Field( categories:dict[str, str] = Field(
default_factory = dict, default_factory = dict,
description = """ description=(
additional name to category ID mappings, see default list at "additional name to category ID mappings (optional). Leave as {} if not needed. "
https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml "See full list at: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml "
"To add: use format 'Category > Subcategory': 'ID'"
Example: ),
categories: examples = ['"Elektronik > Notebooks": "161/278"', '"Jobs > Praktika": "102/125"'],
Elektronik > Notebooks: 161/278
Jobs > Praktika: 102/125
""",
) )
download:DownloadConfig = Field(default_factory = DownloadConfig) download:DownloadConfig = Field(default_factory = DownloadConfig)
@@ -309,7 +343,7 @@ Example:
captcha:CaptchaConfig = Field(default_factory = CaptchaConfig) captcha:CaptchaConfig = Field(default_factory = CaptchaConfig)
update_check:UpdateCheckConfig = Field(default_factory = UpdateCheckConfig, description = "Update check configuration") update_check:UpdateCheckConfig = Field(default_factory = UpdateCheckConfig, description = "Update check configuration")
timeouts:TimeoutConfig = Field(default_factory = TimeoutConfig, description = "Centralized timeout configuration.") timeouts:TimeoutConfig = Field(default_factory = TimeoutConfig, description = "Centralized timeout configuration.")
diagnostics:DiagnosticsConfig | None = Field(default = None, description = "Optional failure-only diagnostics capture.") diagnostics:DiagnosticsConfig = Field(default_factory = DiagnosticsConfig, description = "diagnostics capture configuration for troubleshooting")
def with_values(self, values:dict[str, Any]) -> Config: def with_values(self, values:dict[str, Any]) -> Config:
return Config.model_validate(dicts.apply_defaults(copy.deepcopy(values), defaults = self.model_dump())) return Config.model_validate(dicts.apply_defaults(copy.deepcopy(values), defaults = self.model_dump()))

View File

@@ -6,22 +6,22 @@ from __future__ import annotations
from typing import Literal from typing import Literal
from pydantic import Field
from kleinanzeigen_bot.utils.pydantics import ContextualModel from kleinanzeigen_bot.utils.pydantics import ContextualModel
class UpdateCheckConfig(ContextualModel): class UpdateCheckConfig(ContextualModel):
"""Configuration for update checking functionality. enabled:bool = Field(default = True, description = "whether to check for updates on startup")
channel:Literal["latest", "preview"] = Field(
Attributes: default = "latest", description = "which release channel to check (latest = stable, preview = prereleases)", examples = ["latest", "preview"]
enabled: Whether update checking is enabled. )
channel: Which release channel to check ('latest' for stable, 'preview' for prereleases). interval:str = Field(
interval: How often to check for updates (e.g. '7d', '1d'). default = "7d",
If the interval is invalid, too short (<1d), or too long (>30d), description=(
the bot will log a warning and use a default interval for this run: "how often to check for updates (e.g., 7d, 1d). "
- 1d for 'preview' channel "If invalid, too short (<1d), or too long (>30d), "
- 7d for 'latest' channel "uses defaults: 1d for 'preview' channel, 7d for 'latest' channel"
The config file is not changed automatically; please fix your config to avoid repeated warnings. ),
""" examples = ["7d", "1d", "14d"],
enabled:bool = True )
channel:Literal["latest", "preview"] = "latest"
interval:str = "7d" # Default interval of 7 days

View File

@@ -350,6 +350,8 @@ kleinanzeigen_bot/utils/dicts.py:
"Unsupported file type. The filename \"%s\" must end with *.json, *.yaml, or *.yml": "Nicht unterstützter Dateityp. Der Dateiname \"%s\" muss mit *.json, *.yaml oder *.yml enden" "Unsupported file type. The filename \"%s\" must end with *.json, *.yaml, or *.yml": "Nicht unterstützter Dateityp. Der Dateiname \"%s\" muss mit *.json, *.yaml oder *.yml enden"
save_dict: save_dict:
"Saving [%s]...": "Speichere [%s]..." "Saving [%s]...": "Speichere [%s]..."
save_commented_model:
"Saving [%s]...": "Speichere [%s]..."
load_dict_from_module: load_dict_from_module:
"Loading %s[%s.%s]...": "Lade %s[%s.%s]..." "Loading %s[%s.%s]...": "Lade %s[%s.%s]..."

View File

@@ -8,7 +8,7 @@ from gettext import gettext as _
from importlib.resources import read_text as get_resource_as_string from importlib.resources import read_text as get_resource_as_string
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Any, Final, TypeVar from typing import Any, Final, TypeVar, cast, get_origin
from ruamel.yaml import YAML from ruamel.yaml import YAML
@@ -25,7 +25,7 @@ def apply_defaults(
target:dict[Any, Any], target:dict[Any, Any],
defaults:dict[Any, Any], defaults:dict[Any, Any],
ignore:Callable[[Any, Any], bool] = lambda _k, _v: False, ignore:Callable[[Any, Any], bool] = lambda _k, _v: False,
override:Callable[[Any, Any], bool] = lambda _k, _v: False override:Callable[[Any, Any], bool] = lambda _k, _v: False,
) -> dict[Any, Any]: ) -> dict[Any, Any]:
""" """
>>> apply_defaults({}, {'a': 'b'}) >>> apply_defaults({}, {'a': 'b'})
@@ -48,12 +48,7 @@ def apply_defaults(
for key, default_value in defaults.items(): for key, default_value in defaults.items():
if key in target: if key in target:
if isinstance(target[key], dict) and isinstance(default_value, dict): if isinstance(target[key], dict) and isinstance(default_value, dict):
apply_defaults( apply_defaults(target = target[key], defaults = default_value, ignore = ignore, override = override)
target = target[key],
defaults = default_value,
ignore = ignore,
override = override
)
elif override(key, target[key]): # force overwrite if override says so elif override(key, target[key]): # force overwrite if override says so
target[key] = copy.deepcopy(default_value) target[key] = copy.deepcopy(default_value)
elif not ignore(key, default_value): # only set if not explicitly ignored elif not ignore(key, default_value): # only set if not explicitly ignored
@@ -111,6 +106,24 @@ def load_dict_from_module(module:ModuleType, filename:str, content_label:str = "
return json.loads(content) if filename.endswith(".json") else YAML().load(content) # type: ignore[no-any-return] # mypy return json.loads(content) if filename.endswith(".json") else YAML().load(content) # type: ignore[no-any-return] # mypy
def _configure_yaml() -> YAML:
"""
Configure and return a YAML instance with standard settings.
Returns:
Configured YAML instance ready for dumping
"""
yaml = YAML()
yaml.indent(mapping = 2, sequence = 4, offset = 2)
yaml.representer.add_representer(
str, # use YAML | block style for multi-line strings
lambda dumper, data: dumper.represent_scalar("tag:yaml.org,2002:str", data, style = "|" if "\n" in data else None),
)
yaml.allow_duplicate_keys = False
yaml.explicit_start = False
return yaml
def save_dict(filepath:str | Path, content:dict[str, Any], *, header:str | None = None) -> None: def save_dict(filepath:str | Path, content:dict[str, Any], *, header:str | None = None) -> None:
# Normalize filepath to NFC for cross-platform consistency (issue #728) # Normalize filepath to NFC for cross-platform consistency (issue #728)
# Ensures file paths match NFC-normalized directory names from sanitize_folder_name() # Ensures file paths match NFC-normalized directory names from sanitize_folder_name()
@@ -128,14 +141,7 @@ def save_dict(filepath:str | Path, content:dict[str, Any], *, header:str | None
if filepath.suffix == ".json": if filepath.suffix == ".json":
file.write(json.dumps(content, indent = 2, ensure_ascii = False)) file.write(json.dumps(content, indent = 2, ensure_ascii = False))
else: else:
yaml = YAML() yaml = _configure_yaml()
yaml.indent(mapping = 2, sequence = 4, offset = 2)
yaml.representer.add_representer(str, # use YAML | block style for multi-line strings
lambda dumper, data:
dumper.represent_scalar("tag:yaml.org,2002:str", data, style = "|" if "\n" in data else None)
)
yaml.allow_duplicate_keys = False
yaml.explicit_start = False
yaml.dump(content, file) yaml.dump(content, file)
@@ -153,3 +159,206 @@ def safe_get(a_map:dict[Any, Any], *keys:str) -> Any:
except (KeyError, TypeError): except (KeyError, TypeError):
return None return None
return a_map return a_map
def _should_exclude(field_name:str, exclude:set[str] | dict[str, Any] | None) -> bool:
"""Check if a field should be excluded based on exclude rules."""
if exclude is None:
return False
if isinstance(exclude, set):
return field_name in exclude
if isinstance(exclude, dict):
# If the value is None, it means exclude this field entirely
# If the value is a dict/set, it means nested exclusion rules
if field_name in exclude:
return exclude[field_name] is None
return False
def _get_nested_exclude(field_name:str, exclude:set[str] | dict[str, Any] | None) -> set[str] | dict[str, Any] | None:
"""Get nested exclude rules for a field."""
if exclude is None:
return None
if isinstance(exclude, dict) and field_name in exclude:
nested = exclude[field_name]
# If nested is None, it means exclude entirely - no nested rules to pass down
# If nested is a set or dict, pass it down as nested exclusion rules
if nested is None:
return None
return cast(set[str] | dict[str, Any], nested)
return None
def model_to_commented_yaml(
model_instance:Any,
*,
indent_level:int = 0,
exclude:set[str] | dict[str, Any] | None = None,
) -> Any:
"""
Convert a Pydantic model instance to a structure with YAML comments.
This function recursively processes a Pydantic model and creates a
CommentedMap/CommentedSeq structure with comments based on field descriptions.
The comments are added as block comments above each field.
Args:
model_instance: A Pydantic model instance to convert
indent_level: Current indentation level (for recursive calls)
exclude: Optional set of field names to exclude, or dict for nested exclusion
Returns:
A CommentedMap, CommentedSeq, or primitive value suitable for YAML output
Example:
>>> from pydantic import BaseModel, Field
>>> class Config(BaseModel):
... name: str = Field(default="test", description="The name")
>>> config = Config()
>>> result = model_to_commented_yaml(config)
"""
# Delayed import to avoid circular dependency
from pydantic import BaseModel # noqa: PLC0415
from ruamel.yaml.comments import CommentedMap, CommentedSeq # noqa: PLC0415
# Handle primitive types
if model_instance is None or isinstance(model_instance, (str, int, float, bool)):
return model_instance
# Handle lists/sequences
if isinstance(model_instance, (list, tuple)):
seq = CommentedSeq()
for item in model_instance:
seq.append(model_to_commented_yaml(item, indent_level = indent_level + 1, exclude = exclude))
return seq
# Handle dictionaries (not from Pydantic models)
if isinstance(model_instance, dict) and not isinstance(model_instance, BaseModel):
cmap = CommentedMap()
for key, value in model_instance.items():
if _should_exclude(key, exclude):
continue
cmap[key] = model_to_commented_yaml(value, indent_level = indent_level + 1, exclude = exclude)
return cmap
# Handle Pydantic models
if isinstance(model_instance, BaseModel):
cmap = CommentedMap()
model_class = model_instance.__class__
field_count = 0
# Get field information from the model class
for field_name, field_info in model_class.model_fields.items():
# Skip excluded fields
if _should_exclude(field_name, exclude):
continue
# Get the value from the instance, handling unset required fields
try:
value = getattr(model_instance, field_name)
except AttributeError:
# Field is not set (e.g., required field with no default)
continue
# Add visual separators
if indent_level == 0 and field_count > 0:
# Major section: blank line + prominent separator with 80 # characters
cmap.yaml_set_comment_before_after_key(field_name, before = "\n" + "#" * 80, indent = 0)
elif indent_level > 0:
# Nested fields: always add blank line separator (both between siblings and before first child)
cmap.yaml_set_comment_before_after_key(field_name, before = "", indent = 0)
# Get nested exclude rules for this field
nested_exclude = _get_nested_exclude(field_name, exclude)
# Process the value recursively
processed_value = model_to_commented_yaml(value, indent_level = indent_level + 1, exclude = nested_exclude)
cmap[field_name] = processed_value
field_count += 1
# Build comment from description and examples
comment_parts = []
# Add description if available
description = field_info.description
if description:
comment_parts.append(description)
# Add examples if available
examples = field_info.examples
if examples:
# Check if this is a list field by inspecting type annotation first (handles empty lists),
# then fall back to runtime value type check
is_list_field = get_origin(field_info.annotation) is list or isinstance(value, list)
if is_list_field:
# For list fields, show YAML syntax with field name for clarity
examples_lines = [
"Example usage:",
f" {field_name}:",
*[f" - {ex}" for ex in examples]
]
comment_parts.append("\n".join(examples_lines))
elif len(examples) == 1:
# Single example for scalar field: use singular form without list marker
comment_parts.append(f"Example: {examples[0]}")
else:
# Multiple examples for scalar field: show as alternatives (not list items)
# Use bullets (•) instead of hyphens to distinguish from YAML list syntax
examples_lines = ["Examples (choose one):", *[f"{ex}" for ex in examples]]
comment_parts.append("\n".join(examples_lines))
# Set the comment above the key
if comment_parts:
full_comment = "\n".join(comment_parts)
cmap.yaml_set_comment_before_after_key(field_name, before = full_comment, indent = indent_level * 2)
return cmap
# Fallback: return as-is
return model_instance
def save_commented_model(
filepath:str | Path,
model_instance:Any,
*,
header:str | None = None,
exclude:set[str] | dict[str, Any] | None = None,
) -> None:
"""
Save a Pydantic model to a YAML file with field descriptions as comments.
This function converts a Pydantic model to a commented YAML structure
where each field has its description (and optionally examples) as a
block comment above the key.
Args:
filepath: Path to the output YAML file
model_instance: Pydantic model instance to save
header: Optional header string to write at the top of the file
exclude: Optional set of field names to exclude, or dict for nested exclusion
Example:
>>> from kleinanzeigen_bot.model.config_model import Config
>>> from pathlib import Path
>>> import tempfile
>>> config = Config()
>>> with tempfile.TemporaryDirectory() as tmpdir:
... save_commented_model(Path(tmpdir) / "config.yaml", config, header="# Config file")
"""
filepath = Path(unicodedata.normalize("NFC", str(filepath)))
filepath.parent.mkdir(parents = True, exist_ok = True)
LOG.info("Saving [%s]...", filepath)
# Convert to commented structure directly from model (preserves metadata)
commented_data = model_to_commented_yaml(model_instance, exclude = exclude)
with open(filepath, "w", encoding = "utf-8") as file:
if header:
file.write(header)
file.write("\n")
yaml = _configure_yaml()
yaml.dump(commented_data, file)

View File

@@ -7,7 +7,7 @@ from kleinanzeigen_bot.model.config_model import AdDefaults, Config, TimeoutConf
def test_migrate_legacy_description_prefix() -> None: def test_migrate_legacy_description_prefix() -> None:
assert AdDefaults.model_validate({}).description_prefix is None assert AdDefaults.model_validate({}).description_prefix == "" # noqa: PLC1901 explicit empty check is clearer
assert AdDefaults.model_validate({"description_prefix": "Prefix"}).description_prefix == "Prefix" assert AdDefaults.model_validate({"description_prefix": "Prefix"}).description_prefix == "Prefix"
@@ -19,7 +19,7 @@ def test_migrate_legacy_description_prefix() -> None:
def test_migrate_legacy_description_suffix() -> None: def test_migrate_legacy_description_suffix() -> None:
assert AdDefaults.model_validate({}).description_suffix is None assert AdDefaults.model_validate({}).description_suffix == "" # noqa: PLC1901 explicit empty check is clearer
assert AdDefaults.model_validate({"description_suffix": "Suffix"}).description_suffix == "Suffix" assert AdDefaults.model_validate({"description_suffix": "Suffix"}).description_suffix == "Suffix"

View File

@@ -5,6 +5,8 @@
import unicodedata import unicodedata
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field
def test_save_dict_normalizes_unicode_paths(tmp_path:Path) -> None: def test_save_dict_normalizes_unicode_paths(tmp_path:Path) -> None:
"""Test that save_dict normalizes paths to NFC for cross-platform consistency (issue #728). """Test that save_dict normalizes paths to NFC for cross-platform consistency (issue #728).
@@ -38,3 +40,166 @@ def test_save_dict_normalizes_unicode_paths(tmp_path:Path) -> None:
# Either way, we should have exactly one YAML file total (no duplicates) # Either way, we should have exactly one YAML file total (no duplicates)
all_yaml_files = list(tmp_path.rglob("*.yaml")) all_yaml_files = list(tmp_path.rglob("*.yaml"))
assert len(all_yaml_files) == 1, f"Expected exactly 1 YAML file total, found {len(all_yaml_files)}: {all_yaml_files}" assert len(all_yaml_files) == 1, f"Expected exactly 1 YAML file total, found {len(all_yaml_files)}: {all_yaml_files}"
def test_safe_get_with_type_error() -> None:
"""Test safe_get returns None when accessing a non-dict value (TypeError)."""
from kleinanzeigen_bot.utils import dicts # noqa: PLC0415
# Accessing a key on a string causes TypeError
result = dicts.safe_get({"foo": "bar"}, "foo", "baz")
assert result is None
def test_safe_get_with_empty_dict() -> None:
"""Test safe_get returns empty dict when given empty dict."""
from kleinanzeigen_bot.utils import dicts # noqa: PLC0415
# Empty dict should return the dict itself (falsy but valid)
result = dicts.safe_get({})
assert result == {}
def test_model_to_commented_yaml_with_dict_exclude() -> None:
"""Test model_to_commented_yaml with dict exclude where field is not in exclude dict."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
class TestModel(BaseModel):
included_field:str = Field(default = "value", description = "This field")
excluded_field:str = Field(default = "excluded", description = "Excluded field")
model = TestModel()
# Exclude only excluded_field, included_field should remain
result = model_to_commented_yaml(model, exclude = {"excluded_field": None})
assert "included_field" in result
assert "excluded_field" not in result
def test_model_to_commented_yaml_with_list() -> None:
"""Test model_to_commented_yaml handles list fields correctly."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
class TestModel(BaseModel):
items:list[str] = Field(default_factory = lambda: ["item1", "item2"], description = "List of items")
model = TestModel()
result = model_to_commented_yaml(model)
assert "items" in result
assert isinstance(result["items"], list)
assert result["items"] == ["item1", "item2"]
def test_model_to_commented_yaml_with_multiple_scalar_examples() -> None:
"""Test model_to_commented_yaml formats multiple scalar examples with bullets."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
class TestModel(BaseModel):
choice:str = Field(default = "A", description = "Choose one", examples = ["A", "B", "C"])
model = TestModel()
result = model_to_commented_yaml(model)
# Verify the field exists
assert "choice" in result
# Verify comment was added (check via the yaml_set_comment_before_after_key mechanism)
assert result.ca is not None
def test_model_to_commented_yaml_with_set_exclude() -> None:
"""Test model_to_commented_yaml with set exclude (covers line 170 branch)."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
class TestModel(BaseModel):
field1:str = Field(default = "value1", description = "First field")
field2:str = Field(default = "value2", description = "Second field")
model = TestModel()
# Use set for exclude (not dict)
result = model_to_commented_yaml(model, exclude = {"field2"})
assert "field1" in result
assert "field2" not in result
def test_model_to_commented_yaml_with_nested_dict_exclude() -> None:
"""Test model_to_commented_yaml with nested dict exclude (covers lines 186-187)."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
class NestedModel(BaseModel):
nested_field:str = Field(default = "nested", description = "Nested")
class TestModel(BaseModel):
parent:NestedModel = Field(default_factory = NestedModel, description = "Parent")
model = TestModel()
# Nested exclude with None value
result = model_to_commented_yaml(model, exclude = {"parent": None})
assert "parent" not in result
def test_model_to_commented_yaml_with_plain_dict() -> None:
"""Test model_to_commented_yaml with plain dict (covers lines 238-241)."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
# Plain dict (not a Pydantic model)
plain_dict = {"key1": "value1", "key2": "value2"}
result = model_to_commented_yaml(plain_dict)
assert "key1" in result
assert "key2" in result
assert result["key1"] == "value1"
def test_model_to_commented_yaml_fallback() -> None:
"""Test model_to_commented_yaml fallback for unsupported types (covers line 318)."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
# Custom object that's not a BaseModel, dict, list, or primitive
class CustomObject:
pass
obj = CustomObject()
result = model_to_commented_yaml(obj)
# Should return as-is
assert result is obj
def test_save_commented_model_without_header(tmp_path:Path) -> None:
"""Test save_commented_model without header (covers line 358)."""
from kleinanzeigen_bot.utils.dicts import save_commented_model # noqa: PLC0415
class TestModel(BaseModel):
field:str = Field(default = "value", description = "A field")
model = TestModel()
filepath = tmp_path / "test.yaml"
# Save without header (header=None)
save_commented_model(filepath, model, header = None)
assert filepath.exists()
content = filepath.read_text()
# Should not have a blank line at the start
assert not content.startswith("\n")
def test_model_to_commented_yaml_with_empty_list() -> None:
"""Test model_to_commented_yaml correctly detects empty list fields via type annotation."""
from kleinanzeigen_bot.utils.dicts import model_to_commented_yaml # noqa: PLC0415
class TestModel(BaseModel):
items:list[str] = Field(default_factory = list, description = "List of items", examples = ["item1", "item2"])
model = TestModel()
# Model has empty list, but should still be detected as list field via annotation
result = model_to_commented_yaml(model)
assert "items" in result
assert isinstance(result["items"], list)
assert len(result["items"]) == 0
# Verify comment includes "Example usage:" (list field format) not "Examples:" (scalar format)
assert result.ca is not None