mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-03-12 02:31:45 +01:00
feat: Add descriptive comments and examples to create-config output (#805)
This commit is contained in:
@@ -22,7 +22,11 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"amount": {
|
||||
@@ -36,7 +40,12 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"min_price": {
|
||||
@@ -50,7 +59,12 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"delay_reposts": {
|
||||
|
||||
@@ -4,15 +4,21 @@
|
||||
"properties": {
|
||||
"active": {
|
||||
"default": true,
|
||||
"description": "whether the ad should be published (false = skip this ad)",
|
||||
"title": "Active",
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"default": "OFFER",
|
||||
"description": "type of the ad listing",
|
||||
"enum": [
|
||||
"OFFER",
|
||||
"WANTED"
|
||||
],
|
||||
"examples": [
|
||||
"OFFER",
|
||||
"WANTED"
|
||||
],
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -25,7 +31,8 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
"default": null,
|
||||
"description": "DEPRECATED: Use description_prefix/description_suffix instead"
|
||||
},
|
||||
"description_prefix": {
|
||||
"anyOf": [
|
||||
@@ -36,8 +43,8 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "prefix for the ad description",
|
||||
"default": "",
|
||||
"description": "text to prepend to each ad (optional)",
|
||||
"title": "Description Prefix"
|
||||
},
|
||||
"description_suffix": {
|
||||
@@ -49,38 +56,51 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "suffix for the ad description",
|
||||
"default": "",
|
||||
"description": "text to append to each ad (optional)",
|
||||
"title": "Description Suffix"
|
||||
},
|
||||
"price_type": {
|
||||
"default": "NEGOTIABLE",
|
||||
"description": "pricing strategy for the listing",
|
||||
"enum": [
|
||||
"FIXED",
|
||||
"NEGOTIABLE",
|
||||
"GIVE_AWAY",
|
||||
"NOT_APPLICABLE"
|
||||
],
|
||||
"examples": [
|
||||
"FIXED",
|
||||
"NEGOTIABLE",
|
||||
"GIVE_AWAY",
|
||||
"NOT_APPLICABLE"
|
||||
],
|
||||
"title": "Price Type",
|
||||
"type": "string"
|
||||
},
|
||||
"auto_price_reduction": {
|
||||
"$ref": "#/$defs/AutoPriceReductionConfig",
|
||||
"description": "automatic price reduction configuration"
|
||||
"description": "automatic price reduction configuration for reposted ads"
|
||||
},
|
||||
"shipping_type": {
|
||||
"default": "SHIPPING",
|
||||
"description": "shipping method for the item",
|
||||
"enum": [
|
||||
"PICKUP",
|
||||
"SHIPPING",
|
||||
"NOT_APPLICABLE"
|
||||
],
|
||||
"examples": [
|
||||
"PICKUP",
|
||||
"SHIPPING",
|
||||
"NOT_APPLICABLE"
|
||||
],
|
||||
"title": "Shipping Type",
|
||||
"type": "string"
|
||||
},
|
||||
"sell_directly": {
|
||||
"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",
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -96,14 +116,20 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "default image glob patterns (optional). Leave empty for no default images",
|
||||
"examples": [
|
||||
"\"images/*.jpg\"",
|
||||
"\"photos/*.{png,jpg}\""
|
||||
],
|
||||
"title": "Images"
|
||||
},
|
||||
"contact": {
|
||||
"$ref": "#/$defs/ContactDefaults"
|
||||
"$ref": "#/$defs/ContactDefaults",
|
||||
"description": "default contact information for ads"
|
||||
},
|
||||
"republication_interval": {
|
||||
"default": 7,
|
||||
"description": "number of days between automatic republication of ads",
|
||||
"title": "Republication Interval",
|
||||
"type": "integer"
|
||||
}
|
||||
@@ -133,7 +159,11 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"amount": {
|
||||
@@ -147,7 +177,12 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"min_price": {
|
||||
@@ -161,7 +196,12 @@
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"delay_reposts": {
|
||||
@@ -185,7 +225,12 @@
|
||||
"BrowserConfig": {
|
||||
"properties": {
|
||||
"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": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -201,12 +246,16 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "path to custom browser executable, if not specified will be looked up on PATH",
|
||||
"default": "",
|
||||
"description": "path to custom browser executable (optional). Leave empty to use system default",
|
||||
"title": "Binary Location"
|
||||
},
|
||||
"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": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -215,6 +264,7 @@
|
||||
},
|
||||
"use_private_window": {
|
||||
"default": true,
|
||||
"description": "open browser in private/incognito mode (recommended to avoid cookie conflicts)",
|
||||
"title": "Use Private Window",
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -227,8 +277,8 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"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.",
|
||||
"default": "",
|
||||
"description": "custom browser profile directory (optional). Leave empty for auto-configured default",
|
||||
"title": "User Data Dir"
|
||||
},
|
||||
"profile_name": {
|
||||
@@ -240,7 +290,11 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"default": "",
|
||||
"description": "browser profile name (optional). Leave empty for default profile",
|
||||
"examples": [
|
||||
"\"Profile 1\""
|
||||
],
|
||||
"title": "Profile Name"
|
||||
}
|
||||
},
|
||||
@@ -251,11 +305,18 @@
|
||||
"properties": {
|
||||
"auto_restart": {
|
||||
"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",
|
||||
"type": "boolean"
|
||||
},
|
||||
"restart_delay": {
|
||||
"default": "6h",
|
||||
"description": "duration to wait before retrying after captcha detection (e.g., 1h30m, 6h, 30m)",
|
||||
"examples": [
|
||||
"6h",
|
||||
"1h30m",
|
||||
"30m"
|
||||
],
|
||||
"title": "Restart Delay",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -285,28 +346,16 @@
|
||||
"ContactDefaults": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Name"
|
||||
"default": "",
|
||||
"description": "contact name displayed on the ad",
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"street": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Street"
|
||||
"default": "",
|
||||
"description": "street address for the listing",
|
||||
"title": "Street",
|
||||
"type": "string"
|
||||
},
|
||||
"zipcode": {
|
||||
"anyOf": [
|
||||
@@ -315,41 +364,29 @@
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"default": "",
|
||||
"description": "postal/ZIP code for the listing location",
|
||||
"title": "Zipcode"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"default": "",
|
||||
"description": "city or locality of the listing (can include multiple districts)",
|
||||
"examples": [
|
||||
"Sample Town - District One"
|
||||
],
|
||||
"title": "Location"
|
||||
"title": "Location",
|
||||
"type": "string"
|
||||
},
|
||||
"phone": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
"default": "",
|
||||
"description": "phone number for contact - only available for commercial accounts, personal accounts no longer support this",
|
||||
"examples": [
|
||||
"\"01234 567890\""
|
||||
],
|
||||
"default": null,
|
||||
"title": "Phone"
|
||||
"title": "Phone",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "ContactDefaults",
|
||||
@@ -368,6 +405,7 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "text to prepend to the ad description (deprecated, use description_prefix)",
|
||||
"title": "Prefix"
|
||||
},
|
||||
"suffix": {
|
||||
@@ -380,6 +418,7 @@
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "text to append to the ad description (deprecated, use description_suffix)",
|
||||
"title": "Suffix"
|
||||
}
|
||||
},
|
||||
@@ -430,7 +469,12 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"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": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -458,11 +502,13 @@
|
||||
"LoginConfig": {
|
||||
"properties": {
|
||||
"username": {
|
||||
"description": "kleinanzeigen.de login email or username",
|
||||
"minLength": 1,
|
||||
"title": "Username",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "kleinanzeigen.de login password",
|
||||
"minLength": 1,
|
||||
"title": "Password",
|
||||
"type": "string"
|
||||
@@ -492,11 +538,17 @@
|
||||
}
|
||||
],
|
||||
"default": "AFTER_PUBLISH",
|
||||
"description": "when to delete old versions of republished ads",
|
||||
"examples": [
|
||||
"BEFORE_PUBLISH",
|
||||
"AFTER_PUBLISH",
|
||||
"NEVER"
|
||||
],
|
||||
"title": "Delete Old Ads"
|
||||
},
|
||||
"delete_old_ads_by_title": {
|
||||
"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",
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -657,24 +709,35 @@
|
||||
"type": "object"
|
||||
},
|
||||
"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": {
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"description": "whether to check for updates on startup",
|
||||
"title": "Enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"channel": {
|
||||
"default": "latest",
|
||||
"description": "which release channel to check (latest = stable, preview = prereleases)",
|
||||
"enum": [
|
||||
"latest",
|
||||
"preview"
|
||||
],
|
||||
"examples": [
|
||||
"latest",
|
||||
"preview"
|
||||
],
|
||||
"title": "Channel",
|
||||
"type": "string"
|
||||
},
|
||||
"interval": {
|
||||
"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",
|
||||
"type": "string"
|
||||
}
|
||||
@@ -704,7 +767,11 @@
|
||||
"additionalProperties": {
|
||||
"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",
|
||||
"type": "object"
|
||||
},
|
||||
@@ -734,16 +801,8 @@
|
||||
"description": "Centralized timeout configuration."
|
||||
},
|
||||
"diagnostics": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/DiagnosticsConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Optional failure-only diagnostics capture."
|
||||
"$ref": "#/$defs/DiagnosticsConfig",
|
||||
"description": "diagnostics capture configuration for troubleshooting"
|
||||
}
|
||||
},
|
||||
"title": "Config",
|
||||
|
||||
@@ -579,14 +579,15 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904
|
||||
default_config = Config.model_construct()
|
||||
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
|
||||
dicts.save_dict(
|
||||
dicts.save_commented_model(
|
||||
self.config_file_path,
|
||||
default_config.model_dump(exclude_none = True, exclude = {"ad_defaults": {"description"}}),
|
||||
default_config,
|
||||
header=(
|
||||
"# yaml-language-server: $schema="
|
||||
"https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot"
|
||||
"/refs/heads/main/schemas/config.schema.json"
|
||||
),
|
||||
exclude = {"ad_defaults": {"description"}},
|
||||
)
|
||||
|
||||
def load_config(self) -> None:
|
||||
|
||||
@@ -23,12 +23,19 @@ _MAX_PERCENTAGE:Final[int] = 100
|
||||
class AutoPriceReductionConfig(ContextualModel):
|
||||
enabled:bool = Field(default = False, description = "automatically lower the price of reposted ads")
|
||||
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(
|
||||
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_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):
|
||||
name:str | None = None
|
||||
street:str | None = None
|
||||
zipcode:int | str | None = None
|
||||
location:str | None = Field(
|
||||
default = None, description = "city or locality of the listing (can include multiple districts)", examples = ["Sample Town - District One"]
|
||||
name:str = Field(default = "", description = "contact name displayed on the ad")
|
||||
street:str = Field(default = "", description = "street address for the listing")
|
||||
zipcode:int | str = Field(default = "", description = "postal/ZIP code for the listing location")
|
||||
location:str = Field(
|
||||
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")
|
||||
class DescriptionAffixes(ContextualModel):
|
||||
prefix:str | None = None
|
||||
suffix:str | None = None
|
||||
prefix:str | None = Field(default = None, description = "text to prepend to the ad description (deprecated, use description_prefix)")
|
||||
suffix:str | None = Field(default = None, description = "text to append to the ad description (deprecated, use description_suffix)")
|
||||
|
||||
|
||||
class AdDefaults(ContextualModel):
|
||||
active:bool = True
|
||||
type:Literal["OFFER", "WANTED"] = "OFFER"
|
||||
description:DescriptionAffixes | None = None
|
||||
description_prefix:str | None = Field(default = None, description = "prefix for the ad description")
|
||||
description_suffix:str | None = Field(default = None, description = "suffix for the ad description")
|
||||
price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = "NEGOTIABLE"
|
||||
auto_price_reduction:AutoPriceReductionConfig = Field(default_factory = AutoPriceReductionConfig, description = "automatic price reduction configuration")
|
||||
shipping_type:Literal["PICKUP", "SHIPPING", "NOT_APPLICABLE"] = "SHIPPING"
|
||||
sell_directly:bool = Field(default = False, description = "requires shipping_type SHIPPING to take effect")
|
||||
images:list[str] | None = Field(default = None)
|
||||
contact:ContactDefaults = Field(default_factory = ContactDefaults)
|
||||
republication_interval:int = 7
|
||||
active:bool = Field(default = True, description = "whether the ad should be published (false = skip this ad)")
|
||||
type:Literal["OFFER", "WANTED"] = Field(default = "OFFER", description = "type of the ad listing", examples = ["OFFER", "WANTED"])
|
||||
description:DescriptionAffixes | None = Field(default = None, description = "DEPRECATED: Use description_prefix/description_suffix instead")
|
||||
description_prefix:str | None = Field(default = "", description = "text to prepend to each ad (optional)")
|
||||
description_suffix:str | None = Field(default = "", description = "text to append to each ad (optional)")
|
||||
price_type:Literal["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"] = Field(
|
||||
default = "NEGOTIABLE", description = "pricing strategy for the listing", examples = ["FIXED", "NEGOTIABLE", "GIVE_AWAY", "NOT_APPLICABLE"]
|
||||
)
|
||||
auto_price_reduction:AutoPriceReductionConfig = Field(
|
||||
default_factory = AutoPriceReductionConfig, description = "automatic price reduction configuration for reposted ads"
|
||||
)
|
||||
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")
|
||||
@classmethod
|
||||
@@ -99,7 +122,8 @@ class DownloadConfig(ContextualModel):
|
||||
)
|
||||
excluded_shipping_options:list[str] = Field(
|
||||
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(
|
||||
default = 100,
|
||||
@@ -117,36 +141,49 @@ class BrowserConfig(ContextualModel):
|
||||
arguments:list[str] = Field(
|
||||
default_factory = list,
|
||||
description=(
|
||||
"See https://peter.sh/experiments/chromium-command-line-switches/. "
|
||||
"Browser profile path is auto-configured based on installation mode (portable/XDG)."
|
||||
"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"'],
|
||||
)
|
||||
binary_location:str | None = Field(default = None, description = "path to custom browser executable, if not specified will be looked up on PATH")
|
||||
extensions:list[str] = Field(default_factory = list, description = "a list of .crx extension files to be loaded")
|
||||
use_private_window:bool = True
|
||||
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 = "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(
|
||||
default = None,
|
||||
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."
|
||||
),
|
||||
default = "",
|
||||
description = "custom browser profile directory (optional). Leave empty for auto-configured default",
|
||||
)
|
||||
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):
|
||||
username:str = Field(..., min_length = 1)
|
||||
password:str = Field(..., min_length = 1)
|
||||
username:str = Field(..., min_length = 1, description = "kleinanzeigen.de login email or username")
|
||||
password:str = Field(..., min_length = 1, description = "kleinanzeigen.de login password")
|
||||
|
||||
|
||||
class PublishingConfig(ContextualModel):
|
||||
delete_old_ads:Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = "AFTER_PUBLISH"
|
||||
delete_old_ads_by_title:bool = Field(default = True, description = "only works if delete_old_ads is set to BEFORE_PUBLISH")
|
||||
delete_old_ads:Literal["BEFORE_PUBLISH", "AFTER_PUBLISH", "NEVER"] | None = Field(
|
||||
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):
|
||||
auto_restart:bool = False
|
||||
restart_delay:str = "6h"
|
||||
auto_restart:bool = Field(
|
||||
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):
|
||||
@@ -291,15 +328,12 @@ if relative paths are specified, then they are relative to this configuration fi
|
||||
|
||||
categories:dict[str, str] = Field(
|
||||
default_factory = dict,
|
||||
description = """
|
||||
additional name to category ID mappings, see default list at
|
||||
https://github.com/Second-Hand-Friends/kleinanzeigen-bot/blob/main/src/kleinanzeigen_bot/resources/categories.yaml
|
||||
|
||||
Example:
|
||||
categories:
|
||||
Elektronik > Notebooks: 161/278
|
||||
Jobs > Praktika: 102/125
|
||||
""",
|
||||
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"'],
|
||||
)
|
||||
|
||||
download:DownloadConfig = Field(default_factory = DownloadConfig)
|
||||
@@ -309,7 +343,7 @@ Example:
|
||||
captcha:CaptchaConfig = Field(default_factory = CaptchaConfig)
|
||||
update_check:UpdateCheckConfig = Field(default_factory = UpdateCheckConfig, description = "Update check 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:
|
||||
return Config.model_validate(dicts.apply_defaults(copy.deepcopy(values), defaults = self.model_dump()))
|
||||
|
||||
@@ -6,22 +6,22 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from kleinanzeigen_bot.utils.pydantics import ContextualModel
|
||||
|
||||
|
||||
class UpdateCheckConfig(ContextualModel):
|
||||
"""Configuration for update checking functionality.
|
||||
|
||||
Attributes:
|
||||
enabled: Whether update checking is enabled.
|
||||
channel: Which release channel to check ('latest' for stable, 'preview' for prereleases).
|
||||
interval: How often to check for updates (e.g. '7d', '1d').
|
||||
If the interval is invalid, too short (<1d), or too long (>30d),
|
||||
the bot will log a warning and use a default interval for this run:
|
||||
- 1d for 'preview' channel
|
||||
- 7d for 'latest' channel
|
||||
The config file is not changed automatically; please fix your config to avoid repeated warnings.
|
||||
"""
|
||||
enabled:bool = True
|
||||
channel:Literal["latest", "preview"] = "latest"
|
||||
interval:str = "7d" # Default interval of 7 days
|
||||
enabled:bool = Field(default = True, description = "whether to check for updates on startup")
|
||||
channel:Literal["latest", "preview"] = Field(
|
||||
default = "latest", description = "which release channel to check (latest = stable, preview = prereleases)", examples = ["latest", "preview"]
|
||||
)
|
||||
interval:str = Field(
|
||||
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"],
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
save_dict:
|
||||
"Saving [%s]...": "Speichere [%s]..."
|
||||
save_commented_model:
|
||||
"Saving [%s]...": "Speichere [%s]..."
|
||||
load_dict_from_module:
|
||||
"Loading %s[%s.%s]...": "Lade %s[%s.%s]..."
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from gettext import gettext as _
|
||||
from importlib.resources import read_text as get_resource_as_string
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Final, TypeVar
|
||||
from typing import Any, Final, TypeVar, cast, get_origin
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
@@ -25,7 +25,7 @@ def apply_defaults(
|
||||
target:dict[Any, Any],
|
||||
defaults:dict[Any, Any],
|
||||
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]:
|
||||
"""
|
||||
>>> apply_defaults({}, {'a': 'b'})
|
||||
@@ -48,12 +48,7 @@ def apply_defaults(
|
||||
for key, default_value in defaults.items():
|
||||
if key in target:
|
||||
if isinstance(target[key], dict) and isinstance(default_value, dict):
|
||||
apply_defaults(
|
||||
target = target[key],
|
||||
defaults = default_value,
|
||||
ignore = ignore,
|
||||
override = override
|
||||
)
|
||||
apply_defaults(target = target[key], defaults = default_value, ignore = ignore, override = override)
|
||||
elif override(key, target[key]): # force overwrite if override says so
|
||||
target[key] = copy.deepcopy(default_value)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
# Normalize filepath to NFC for cross-platform consistency (issue #728)
|
||||
# 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":
|
||||
file.write(json.dumps(content, indent = 2, ensure_ascii = False))
|
||||
else:
|
||||
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
|
||||
yaml = _configure_yaml()
|
||||
yaml.dump(content, file)
|
||||
|
||||
|
||||
@@ -153,3 +159,206 @@ def safe_get(a_map:dict[Any, Any], *keys:str) -> Any:
|
||||
except (KeyError, TypeError):
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -7,7 +7,7 @@ from kleinanzeigen_bot.model.config_model import AdDefaults, Config, TimeoutConf
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -19,7 +19,7 @@ def test_migrate_legacy_description_prefix() -> 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"
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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).
|
||||
@@ -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)
|
||||
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}"
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user