From a2abaca347fed4cb9f28154afd0eaf6d1621eb9e Mon Sep 17 00:00:00 2001 From: Bo Peng Date: Fri, 31 Jan 2025 14:37:59 -0600 Subject: [PATCH] Code improvement --- poetry.lock | 22 ++++-- pyproject.toml | 1 + src/ai_marketplace_monitor/ai.py | 16 ++--- src/ai_marketplace_monitor/config.py | 40 ++++------- src/ai_marketplace_monitor/config.toml | 11 +++ src/ai_marketplace_monitor/facebook.py | 49 +++++--------- src/ai_marketplace_monitor/item.py | 18 ++--- src/ai_marketplace_monitor/marketplace.py | 26 +++++++- src/ai_marketplace_monitor/monitor.py | 81 +++++++++++------------ src/ai_marketplace_monitor/utils.py | 13 ++++ 10 files changed, 146 insertions(+), 131 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6f6f29a..b6657d4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -754,6 +754,20 @@ http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "humanize" +version = "4.11.0" +description = "Python humanize utilities" +optional = false +python-versions = ">=3.9" +files = [ + {file = "humanize-4.11.0-py3-none-any.whl", hash = "sha256:b53caaec8532bcb2fff70c8826f904c35943f8cecaca29d272d9df38092736c0"}, + {file = "humanize-4.11.0.tar.gz", hash = "sha256:e66f36020a2d5a974c504bd2555cf770621dbdbb6d82f94a6857c0b1ea2608be"}, +] + +[package.extras] +tests = ["freezegun", "pytest", "pytest-cov"] + [[package]] name = "identify" version = "2.6.6" @@ -1139,13 +1153,13 @@ files = [ [[package]] name = "openai" -version = "1.60.2" +version = "1.61.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" files = [ - {file = "openai-1.60.2-py3-none-any.whl", hash = "sha256:993bd11b96900b9098179c728026f016b4982ded7ee30dfcf4555eab1171fff9"}, - {file = "openai-1.60.2.tar.gz", hash = "sha256:a8f843e10f2855713007f491d96afb2694b11b5e02cb97c7d01a0be60bc5bb51"}, + {file = "openai-1.61.0-py3-none-any.whl", hash = "sha256:e8c512c0743accbdbe77f3429a1490d862f8352045de8dc81969301eb4a4f666"}, + {file = "openai-1.61.0.tar.gz", hash = "sha256:216f325a24ed8578e929b0f1b3fb2052165f3b04b0461818adaa51aa29c71f8a"}, ] [package.dependencies] @@ -2220,4 +2234,4 @@ tests-strict = ["pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)"] [metadata] lock-version = "2.0" python-versions = "<3.13,>=3.10" -content-hash = "4d50df08402cbfe555206265614a311a2e2309d81b17338a7c54b96199bf7265" +content-hash = "3ed6255341c57e4cf8704993aebed757988c51f35c999a6755cdd984c9bae46b" diff --git a/pyproject.toml b/pyproject.toml index 1ccae3a..aa5e7e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ diskcache = "^5.6.3" watchdog = "^6.0.0" openai = "^1.60.1" parsedatetime = "^2.6" +humanize = "^4.11.0" schedule = "^1.2.2" tomli = { version = "2.2.1", markers = "python_version < '3.11'" } diff --git a/src/ai_marketplace_monitor/ai.py b/src/ai_marketplace_monitor/ai.py index 3b69e0f..df6b23f 100644 --- a/src/ai_marketplace_monitor/ai.py +++ b/src/ai_marketplace_monitor/ai.py @@ -49,10 +49,8 @@ def get_config(cls: Type["AIBackend"], **kwargs: Any) -> TAIConfig: def connect(self: "AIBackend") -> None: raise NotImplementedError("Connect method must be implemented by subclasses.") - def get_prompt( - self: "AIBackend", listing: SearchedItem, item_name: str, item_config: TItemConfig - ) -> str: - prompt = f"""A user would like to buy a {item_name} from facebook marketplace. + def get_prompt(self: "AIBackend", listing: SearchedItem, item_config: TItemConfig) -> str: + prompt = f"""A user would like to buy a {item_config.name} from facebook marketplace. He used keywords "{'" and "'.join(item_config.keywords)}" to perform the search.""" if item_config.description: prompt += f""" He also added description "{item_config.description}" to describe the item he is interested in.""" @@ -83,9 +81,7 @@ def get_prompt( self.logger.debug(f"Prompt: {prompt}") return prompt - def confirm( - self: "AIBackend", listing: SearchedItem, item_name: str, item_config: TItemConfig - ) -> bool: + def confirm(self: "AIBackend", listing: SearchedItem, item_config: TItemConfig) -> bool: raise NotImplementedError("Confirm method must be implemented by subclasses.") @@ -106,11 +102,9 @@ def connect(self: "OpenAIBackend") -> None: timeout=10, ) - def confirm( - self: "OpenAIBackend", listing: SearchedItem, item_name: str, item_config: TItemConfig - ) -> bool: + def confirm(self: "OpenAIBackend", listing: SearchedItem, item_config: TItemConfig) -> bool: # ask openai to confirm the item is correct - prompt = self.get_prompt(listing, item_name, item_config) + prompt = self.get_prompt(listing, item_config) assert self.client is not None diff --git a/src/ai_marketplace_monitor/config.py b/src/ai_marketplace_monitor/config.py index 8cc96d6..f007e6e 100644 --- a/src/ai_marketplace_monitor/config.py +++ b/src/ai_marketplace_monitor/config.py @@ -1,6 +1,7 @@ import os import sys from dataclasses import dataclass, field +from itertools import chain from logging import Logger from typing import Any, Dict, Generic, List @@ -145,37 +146,20 @@ def validate_users(self: "Config") -> None: def expand_regions(self: "Config") -> None: # if region is specified in other section, they must exist - for marketplace_config in self.marketplace.values(): - if marketplace_config.search_region is None: + for config in chain(self.marketplace.values(), self.item.values()): + if config.search_region is None: continue + config.search_city = [] + config.radius = [] - marketplace_config.search_city = [] - marketplace_config.radius = [] - - for region in marketplace_config.search_region: + for region in config.search_region: region_config: RegionConfig = self.region[region] if region not in self.region: raise ValueError( - f"Region [magenta]{region}[/magenta] specified in [magenta]{marketplace_config.name}[/magenta] does not exist." - ) - # if region is specified, expand it into search_city - marketplace_config.search_city.extend(region_config.search_city) - # set radius, if market_config already has radius, they should be the same - marketplace_config.radius.extend(region_config.radius) - - # if region is specified in any of the items, do the same - for item_config in self.item.values(): - # expand region into item_config's search_city - if item_config.search_region is None: - continue - item_config.search_city = [] - item_config.radius = [] - for region in item_config.search_region: - region_config = self.region[region] - if region not in self.region: - raise ValueError( - f"Region [magenta]{region}[/magenta] specified in [magenta]{item_config.name}[/magenta] does not exist." + f"Region [magenta]{region}[/magenta] specified in [magenta]{config.name}[/magenta] does not exist." ) - # if region is specified, expand it into search_city - item_config.search_city.extend(region_config.search_city) - item_config.radius.extend(region_config.radius) + # avoid duplicated addition of search_city + for search_city, radius in zip(region_config.search_city, region_config.radius): + if search_city not in config.search_city: + config.search_city.append(search_city) + config.radius.append(radius) diff --git a/src/ai_marketplace_monitor/config.toml b/src/ai_marketplace_monitor/config.toml index d97d099..109f492 100644 --- a/src/ai_marketplace_monitor/config.toml +++ b/src/ai_marketplace_monitor/config.toml @@ -1,3 +1,14 @@ +# +# Region definitions. +# - full_name and city_name are for readability/booktracking purpose only +# - different radius can be used for different search_city. In this case +# radius should an array with the same length as search_city +# +# Usage: +# - ·search_city` will be ignored if `search_region` is specified. +# - under the hood search_city is replaced by `search_city` of the regions +# + [region.usa] full_name = "USA (without AK or HI)" radius = 500 diff --git a/src/ai_marketplace_monitor/facebook.py b/src/ai_marketplace_monitor/facebook.py index f5132a0..55c8859 100644 --- a/src/ai_marketplace_monitor/facebook.py +++ b/src/ai_marketplace_monitor/facebook.py @@ -14,6 +14,7 @@ from .item import SearchedItem from .marketplace import ItemConfig, Marketplace, MarketplaceConfig from .utils import ( + CacheType, DataClassWithHandleFunc, cache, convert_to_seconds, @@ -49,14 +50,17 @@ class Availability(Enum): @dataclass class FacebookMarketItemCommonConfig(DataClassWithHandleFunc): + """Item options that can be defined in marketplace + + This class defines and processes options that can be specified + in both marketplace and item sections, specific to facebook marketplace + """ + acceptable_locations: List[str] | None = None availability: str | None = None condition: List[str] | None = None date_listed: str | None = None delivery_method: str | None = None - exclude_sellers: List[str] | None = None - max_price: int | None = None - min_price: int | None = None def handle_acceptable_locations(self: "FacebookMarketItemCommonConfig") -> None: if self.acceptable_locations is None: @@ -113,35 +117,15 @@ def handle_delivery_method(self: "FacebookMarketItemCommonConfig") -> None: f"Item [magenta]{self.name}[/magenta] delivery_method must be one of 'local_pick_up' and 'shipping'." ) - def handle_exclude_sellers(self: "FacebookMarketItemCommonConfig") -> None: - if self.exclude_sellers is None: - return - - if isinstance(self.exclude_sellers, str): - self.exclude_sellers = [self.exclude_sellers] - if not isinstance(self.exclude_sellers, list) or not all( - isinstance(x, str) for x in self.exclude_sellers - ): - raise ValueError( - f"Item [magenta]{self.name}[/magenta] exclude_sellers must be a list." - ) - - def handle_max_price(self: "FacebookMarketItemCommonConfig") -> None: - if self.max_price is None: - return - if not isinstance(self.max_price, int): - raise ValueError(f"Item [magenta]{self.name}[/magenta] max_price must be an integer.") - - def handle_min_price(self: "FacebookMarketItemCommonConfig") -> None: - if self.min_price is None: - return - - if not isinstance(self.min_price, int): - raise ValueError(f"Item [magenta]{self.name}[/magenta] min_price must be an integer.") - @dataclass class FacebookMarketplaceConfig(MarketplaceConfig, FacebookMarketItemCommonConfig): + """Options specific to facebook marketplace + + This class defines and processes options that can be specified + in the marketplace.facebook section only. None of the options are required. + """ + login_wait_time: int | None = None password: str | None = None username: str | None = None @@ -305,6 +289,7 @@ def search( # so we do not copy title, description etc from the detailed result item.description = details.description item.seller = details.seller + item.name = item_config.name self.logger.debug( f"""New item "{item.title}" from https://www.facebook.com{item.post_url} is sold by "{item.seller}" and with description "{item.description[:100]}..." """ ) @@ -312,7 +297,7 @@ def search( yield item def get_item_details(self: "FacebookMarketplace", post_url: str) -> SearchedItem: - details = cache.get(("get_item_details", post_url)) + details = cache.get((CacheType.ITEM_DETAILS.value, post_url)) if details is not None: return details @@ -322,7 +307,7 @@ def get_item_details(self: "FacebookMarketplace", post_url: str) -> SearchedItem assert self.page is not None self.goto_url(f"https://www.facebook.com{post_url}") details = FacebookItemPage(self.page.content(), self.logger).parse(post_url) - cache.set(("get_item_details", post_url), details, tag="item_details") + cache.set((CacheType.ITEM_DETAILS.value, post_url), details, tag="item_details") return details def filter_item( @@ -441,6 +426,7 @@ def parse_listing( # Append the parsed data to the list. return SearchedItem( marketplace="facebook", + name="", id=post_url.split("?")[0].rstrip("/").split("/")[-1], title=title, image=image, @@ -546,6 +532,7 @@ def parse(self: "FacebookItemPage", post_url: str) -> SearchedItem: self.logger.info(f"Parsing item [magenta]{title}[/magenta]") res = SearchedItem( marketplace="facebook", + name="", id=item_id, title=title, image=self.get_image_url(), diff --git a/src/ai_marketplace_monitor/item.py b/src/ai_marketplace_monitor/item.py index 5137381..da2a88f 100644 --- a/src/ai_marketplace_monitor/item.py +++ b/src/ai_marketplace_monitor/item.py @@ -1,12 +1,13 @@ -from dataclasses import dataclass, field -from typing import List +from dataclasses import dataclass +from typing import Tuple -from .utils import DataClassWithHandleFunc +from .utils import CacheType @dataclass class SearchedItem: marketplace: str + name: str # unique identification id: str title: str @@ -17,11 +18,6 @@ class SearchedItem: seller: str description: str - -@dataclass -class ItemConfig(DataClassWithHandleFunc): - """Generic item config""" - - notify: List[str] = field(default_factory=list) - search_interval: int = 30 - max_search_interval: int = 60 + @property + def user_notified_key(self: "SearchedItem") -> Tuple[str, str, str]: + return (CacheType.USER_NOTIFIED.value, self.marketplace, self.id) diff --git a/src/ai_marketplace_monitor/marketplace.py b/src/ai_marketplace_monitor/marketplace.py index 18859b8..3816d1d 100644 --- a/src/ai_marketplace_monitor/marketplace.py +++ b/src/ai_marketplace_monitor/marketplace.py @@ -11,7 +11,13 @@ @dataclass class MarketItemCommonConfig(DataClassWithHandleFunc): + """Item options that can be specified in market (non-marketplace specifc) + This class defines and processes options that can be specified + in both marketplace and item sections, generic to all marketplaces + """ + + exclude_sellers: List[str] | None = None max_search_interval: int | None = None notify: List[str] | None = None search_city: List[str] | None = None @@ -22,6 +28,19 @@ class MarketItemCommonConfig(DataClassWithHandleFunc): max_price: int | None = None min_price: int | None = None + def handle_exclude_sellers(self: "MarketItemCommonConfig") -> None: + if self.exclude_sellers is None: + return + + if isinstance(self.exclude_sellers, str): + self.exclude_sellers = [self.exclude_sellers] + if not isinstance(self.exclude_sellers, list) or not all( + isinstance(x, str) for x in self.exclude_sellers + ): + raise ValueError( + f"Item [magenta]{self.name}[/magenta] exclude_sellers must be a list." + ) + def handle_max_search_interval(self: "MarketItemCommonConfig") -> None: if self.max_search_interval is None: return @@ -137,14 +156,15 @@ class MarketplaceConfig(MarketItemCommonConfig): @dataclass class ItemConfig(MarketItemCommonConfig): - """Generic item config""" + """This class defined options that can only be specified for items.""" + # keywords is required, all others are optional keywords: List[str] = field(default_factory=list) exclude_keywords: List[str] | None = None exclude_by_description: List[str] | None = None description: str | None = None enabled: bool | None = None - marketplace: str = "facebook" + marketplace: str | None = None def handle_keywords(self: "ItemConfig") -> None: if isinstance(self.keywords, str): @@ -202,7 +222,7 @@ def get_config(cls: Type["Marketplace"], **kwargs: Any) -> TMarketplaceConfig: def get_item_config(cls: Type["Marketplace"], **kwargs: Any) -> TItemConfig: raise NotImplementedError("get_config method must be implemented by subclasses.") - def configure(self: "Marketplace", config: MarketplaceConfig) -> None: + def configure(self: "Marketplace", config: TMarketplaceConfig) -> None: self.config = config def set_browser(self: "Marketplace", browser: Browser) -> None: diff --git a/src/ai_marketplace_monitor/monitor.py b/src/ai_marketplace_monitor/monitor.py index 4bd6fea..77452de 100644 --- a/src/ai_marketplace_monitor/monitor.py +++ b/src/ai_marketplace_monitor/monitor.py @@ -3,6 +3,7 @@ from logging import Logger from typing import ClassVar, List +import humanize import schedule # type: ignore from playwright.sync_api import Browser, Playwright, sync_playwright from rich.pretty import pretty_repr @@ -12,7 +13,7 @@ from .item import SearchedItem from .marketplace import Marketplace, TItemConfig, TMarketplaceConfig from .user import User -from .utils import cache, calculate_file_hash, sleep_with_watchdog +from .utils import CacheType, cache, calculate_file_hash, sleep_with_watchdog, time_until_next_run class MarketplaceMonitor: @@ -79,8 +80,6 @@ def load_ai_agents(self: "MarketplaceMonitor") -> None: self.ai_agents.append(ai_class(config=ai_config, logger=self.logger)) self.ai_agents[-1].connect() self.logger.info(f"Connected to {ai_config.name}") - # if one works, do not try to load another one - break except Exception as e: self.logger.error(f"Error connecting to {ai_config.name}: {e}") continue @@ -95,31 +94,31 @@ def search_item( self.logger.info( f"Searching {marketplace_config.name} for [magenta]{item_config.name}[/magenta]" ) - new_items = [] + new_listings = [] # users to notify is determined from item, then marketplace, then all users assert self.config is not None users_to_notify = ( item_config.notify or marketplace_config.notify or list(self.config.user.keys()) ) - for item in marketplace.search(item_config): + for listing in marketplace.search(item_config): # if everyone has been notified - if ("notify_user", item.id) in cache and all( - user in cache.get(("notify_user", item.id), ()) for user in users_to_notify + if listing.user_notified_key in cache and all( + user in cache.get(listing.user_notified_key, ()) for user in users_to_notify ): self.logger.info( - f"Already sent notification for item [magenta]{item.title}[/magenta], skipping." + f"Already sent notification for item [magenta]{listing.title}[/magenta], skipping." ) continue # for x in self.find_new_items(found_items) - if not self.confirmed_by_ai(item, item_name=item_config.name, item_config=item_config): + if not self.confirmed_by_ai(listing, item_config=item_config): continue - new_items.append(item) + new_listings.append(listing) self.logger.info( - f"""[magenta]{len(new_items)}[/magenta] new listing{"" if len(new_items) == 1 else "s"} for {item_config.name} {"is" if len(new_items) == 1 else "are"} found.""" + f"""[magenta]{len(new_listings)}[/magenta] new listing{"" if len(new_listings) == 1 else "s"} for {item_config.name} {"is" if len(new_listings) == 1 else "are"} found.""" ) - if new_items: - self.notify_users(users_to_notify, new_items) + if new_listings: + self.notify_users(users_to_notify, new_listings) time.sleep(5) def schedule_jobs(self: "MarketplaceMonitor") -> None: @@ -170,7 +169,7 @@ def schedule_jobs(self: "MarketplaceMonitor") -> None: search_interval, ) self.logger.info( - f"Scheduling to search for {item_config.name} every {search_interval} {'' if search_interval == max_search_interval else f'to {max_search_interval}'} minutes" + f"Scheduling to search for {item_config.name} every {humanize.naturaldelta(search_interval)} {'' if search_interval == max_search_interval else f'to {humanize.naturaldelta(max_search_interval)}'}" ) schedule.every(search_interval).to(max_search_interval).seconds.do( self.search_item, @@ -183,13 +182,12 @@ def start_monitor(self: "MarketplaceMonitor") -> None: """Main function to monitor the marketplace.""" while True: self.schedule_jobs() - # run all jobs at the first time, then on their own - # schedule + # run all jobs at the first time, then on their own schedule schedule.run_all() while True: schedule.run_pending() sleep_with_watchdog( - 60, + time_until_next_run(), self.config_files, ) # if configuration file has been changed, clear all scheduled jobs and restart @@ -266,7 +264,7 @@ def check_items( marketplace.configure(marketplace_config) # do we need a browser? - if ("get_item_details", post_url) not in cache: + if (CacheType.ITEM_DETAILS.value, post_url) not in cache: if browser is None: self.logger.info( "Starting a browser because the item was not checked before." @@ -276,7 +274,7 @@ def check_items( # ignore enabled # do not search, get the item details directly - listing = marketplace.get_item_details(post_url) + listing: SearchedItem = marketplace.get_item_details(post_url) self.logger.info(f"Details of the item is found: {pretty_repr(listing)}") @@ -287,20 +285,18 @@ def check_items( f"Checking {post_url} for item {item_config.name} with configuration {pretty_repr(item_config)}" ) marketplace.filter_item(listing, item_config) - self.confirmed_by_ai( - listing, item_name=item_config.name, item_config=item_config - ) - if ("notify_user", listing["id"]) in cache: + self.confirmed_by_ai(listing, item_config=item_config) + if listing.user_notified_key in cache: self.logger.info( f"Already sent notification for item {item_config.name}." ) def confirmed_by_ai( - self: "MarketplaceMonitor", item: SearchedItem, item_name: str, item_config: TItemConfig + self: "MarketplaceMonitor", item: SearchedItem, item_config: TItemConfig ) -> bool: for agent in self.ai_agents: try: - return agent.confirm(item, item_name, item_config) + return agent.confirm(item, item_config) except Exception as e: self.logger.error(f"Failed to get an answer from {agent.config.name}: {e}") continue @@ -308,33 +304,29 @@ def confirmed_by_ai( return True def notify_users( - self: "MarketplaceMonitor", users: List[str], items: List[SearchedItem] + self: "MarketplaceMonitor", users: List[str], listings: List[SearchedItem] ) -> None: - # we cache notified user in the format of - # - # ("notify_user", item_id) = (user1, user2, user3) - # # get notification msg for this item for user in users: msgs = [] - unnotified_items = [] - for item in items: - if ("notify_user", item.id) in cache and user in cache.get( - ("notify_user", item.id), () + unnotified_listings = [] + for listing in listings: + if listing.user_notified_key in cache and user in cache.get( + listing.user_notified_key, () ): continue self.logger.info( - f"""New item found: {item.title} with URL https://www.facebook.com{item.post_url} for user {user}""" + f"""New item found: {listing.title} with URL https://www.facebook.com{listing.post_url} for user {user}""" ) msgs.append( - f"""{item.title}\n{item.price}, {item.location}\nhttps://www.facebook.com{item.post_url}""" + f"""{listing.title}\n{listing.price}, {listing.location}\nhttps://www.facebook.com{listing.post_url}""" ) - unnotified_items.append(item) + unnotified_listings.append(listing) - if not unnotified_items: + if not unnotified_listings: continue - title = f"Found {len(msgs)} new item from {item.marketplace}: " + title = f"Found {len(msgs)} new {listing.name} from {listing.marketplace}: " message = "\n\n".join(msgs) self.logger.info( f"Sending {user} a message with title [magenta]{title}[/magenta] and message [magenta]{message}[/magenta]" @@ -343,11 +335,14 @@ def notify_users( assert self.config.user is not None try: User(user, self.config.user[user], logger=self.logger).notify(title, message) - for item in unnotified_items: + for listing in unnotified_listings: cache.set( - ("notify_user", item.id), - (user, *cache.get(("notify_user", item.id), ())), - tag="notify_user", + listing.user_notified_key, + ( + user, + *cache.get(listing.user_notified_key, ()), + ), + tag=CacheType.USER_NOTIFIED.value, ) except Exception as e: self.logger.error(f"Failed to notify {user}: {e}") diff --git a/src/ai_marketplace_monitor/utils.py b/src/ai_marketplace_monitor/utils.py index 78c2696..4091694 100644 --- a/src/ai_marketplace_monitor/utils.py +++ b/src/ai_marketplace_monitor/utils.py @@ -3,9 +3,11 @@ import re import time from dataclasses import dataclass, fields +from enum import Enum from typing import Any, Dict, List, TypeVar import parsedatetime # type: ignore +import schedule from diskcache import Cache # type: ignore from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.observers import Observer @@ -31,6 +33,11 @@ def __post_init__(self: "DataClassWithHandleFunc") -> None: handle_method() +class CacheType(Enum): + ITEM_DETAILS = "get_item_details" + USER_NOTIFIED = "notify_user" + + def calculate_file_hash(file_paths: List[str]) -> str: """Calculate the SHA-256 hash of the file content.""" hasher = hashlib.sha256() @@ -135,3 +142,9 @@ def convert_to_seconds(time_str: str) -> int: cal = parsedatetime.Calendar(version=parsedatetime.VERSION_CONTEXT_STYLE) time_struct, _ = cal.parse(time_str) return int(time.mktime(time_struct) - time.mktime(time.localtime())) + + +def time_until_next_run() -> int: + next_run = min(job.next_run for job in schedule.jobs) + now = time.time() + return max(next_run.timestamp() - now, 0)