mirror of
https://framagit.org/framasoft/framaspace/argos.git
synced 2025-04-28 18:02:41 +02:00
✨ — Add a setting to set a reschedule delay if check failed (fix #67)
BREAKING CHANGE: `mo` is no longer accepted for declaring a duration in month in the configuration
You need to use `M`, `month` or `months`
Bonus: ✨ - Allow to choose a frequency smaller than a minute
This commit is contained in:
parent
0563cf185a
commit
5b999184d0
10 changed files with 150 additions and 113 deletions
|
@ -9,6 +9,10 @@
|
||||||
- ♻️ — Refactor some agent code
|
- ♻️ — Refactor some agent code
|
||||||
- 💄 — Filter form on domains list (#66)
|
- 💄 — Filter form on domains list (#66)
|
||||||
- ✨ — Add "Remember me" checkbox on login (#65)
|
- ✨ — Add "Remember me" checkbox on login (#65)
|
||||||
|
- ✨ — Add a setting to set a reschedule delay if check failed (#67)
|
||||||
|
BREAKING CHANGE: `mo` is no longer accepted for declaring a duration in month in the configuration
|
||||||
|
You need to use `M`, `month` or `months`
|
||||||
|
- ✨ - Allow to choose a frequency smaller than a minute
|
||||||
|
|
||||||
## 0.5.0
|
## 0.5.0
|
||||||
|
|
||||||
|
|
|
@ -20,17 +20,25 @@ general:
|
||||||
|
|
||||||
# Session duration
|
# Session duration
|
||||||
# Use m for minutes, h for hours, d for days
|
# Use m for minutes, h for hours, d for days
|
||||||
# w for weeks, mo for months, y for years
|
# w for weeks, M for months, y for years
|
||||||
|
# See https://github.com/timwedde/durations_nlp#scales-reference for details
|
||||||
# If not present, default value is "7d"
|
# If not present, default value is "7d"
|
||||||
session_duration: "7d"
|
session_duration: "7d"
|
||||||
# Session opened with "Remember me" checked
|
# Session opened with "Remember me" checked
|
||||||
# If not present, the "Remember me" feature is not available
|
# If not present, the "Remember me" feature is not available
|
||||||
# remember_me_duration: "1mo"
|
# remember_me_duration: "1M"
|
||||||
|
|
||||||
# Default delay for checks.
|
# Default delay for checks.
|
||||||
# Can be superseeded in domain configuration.
|
# Can be superseeded in domain configuration.
|
||||||
# For ex., to run checks every minute:
|
# For ex., to run checks every 5 minutes:
|
||||||
frequency: "1m"
|
frequency: "5m"
|
||||||
|
# Default re-check delay if a check has failed.
|
||||||
|
# Can be superseeded in domain configuration.
|
||||||
|
# If not present, failed checked won’t be re-checked (they will be
|
||||||
|
# run again like if they succeded
|
||||||
|
# For ex., to re-try a check one minute after a failure:
|
||||||
|
# recheck_delay: "1m"
|
||||||
|
|
||||||
# Which way do you want to be warned when a check goes to that severity?
|
# Which way do you want to be warned when a check goes to that severity?
|
||||||
# "local" emits a message in the server log
|
# "local" emits a message in the server log
|
||||||
# You’ll need to configure mail, gotify or apprise below to be able to use
|
# You’ll need to configure mail, gotify or apprise below to be able to use
|
||||||
|
@ -179,6 +187,7 @@ websites:
|
||||||
- json-is: '{"foo": "bar", "baz": 42}'
|
- json-is: '{"foo": "bar", "baz": 42}'
|
||||||
- domain: "https://munin.example.org"
|
- domain: "https://munin.example.org"
|
||||||
frequency: "20m"
|
frequency: "20m"
|
||||||
|
recheck_delay: "5m"
|
||||||
paths:
|
paths:
|
||||||
- path: "/"
|
- path: "/"
|
||||||
checks:
|
checks:
|
||||||
|
|
|
@ -5,8 +5,9 @@ For database models, see argos.server.models.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from typing import Dict, List, Literal, Optional, Tuple
|
from typing import Dict, List, Literal, Tuple
|
||||||
|
|
||||||
|
from durations_nlp import Duration
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
BaseModel,
|
BaseModel,
|
||||||
ConfigDict,
|
ConfigDict,
|
||||||
|
@ -22,7 +23,7 @@ from pydantic.networks import UrlConstraints
|
||||||
from pydantic_core import Url
|
from pydantic_core import Url
|
||||||
from typing_extensions import Annotated
|
from typing_extensions import Annotated
|
||||||
|
|
||||||
from argos.schemas.utils import string_to_duration, Method
|
from argos.schemas.utils import Method
|
||||||
|
|
||||||
Severity = Literal["warning", "error", "critical", "unknown"]
|
Severity = Literal["warning", "error", "critical", "unknown"]
|
||||||
Environment = Literal["dev", "test", "production"]
|
Environment = Literal["dev", "test", "production"]
|
||||||
|
@ -37,7 +38,7 @@ SQLiteDsn = Annotated[
|
||||||
def parse_threshold(value):
|
def parse_threshold(value):
|
||||||
"""Parse duration threshold for SSL certificate validity"""
|
"""Parse duration threshold for SSL certificate validity"""
|
||||||
for duration_str, severity in value.items():
|
for duration_str, severity in value.items():
|
||||||
days = string_to_duration(duration_str, "days")
|
days = Duration(duration_str).to_days()
|
||||||
# Return here because it's one-item dicts.
|
# Return here because it's one-item dicts.
|
||||||
return (days, severity)
|
return (days, severity)
|
||||||
|
|
||||||
|
@ -115,14 +116,23 @@ class WebsitePath(BaseModel):
|
||||||
|
|
||||||
class Website(BaseModel):
|
class Website(BaseModel):
|
||||||
domain: HttpUrl
|
domain: HttpUrl
|
||||||
frequency: Optional[int] = None
|
frequency: float | None = None
|
||||||
|
recheck_delay: float | None = None
|
||||||
paths: List[WebsitePath]
|
paths: List[WebsitePath]
|
||||||
|
|
||||||
@field_validator("frequency", mode="before")
|
@field_validator("frequency", mode="before")
|
||||||
def parse_frequency(cls, value):
|
def parse_frequency(cls, value):
|
||||||
"""Convert the configured frequency to minutes"""
|
"""Convert the configured frequency to minutes"""
|
||||||
if value:
|
if value:
|
||||||
return string_to_duration(value, "minutes")
|
return Duration(value).to_minutes()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@field_validator("recheck_delay", mode="before")
|
||||||
|
def parse_recheck_delay(cls, value):
|
||||||
|
"""Convert the configured recheck delay to minutes"""
|
||||||
|
if value:
|
||||||
|
return Duration(value).to_minutes()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -148,7 +158,7 @@ class Mail(BaseModel):
|
||||||
port: PositiveInt = 25
|
port: PositiveInt = 25
|
||||||
ssl: StrictBool = False
|
ssl: StrictBool = False
|
||||||
starttls: StrictBool = False
|
starttls: StrictBool = False
|
||||||
auth: Optional[MailAuth] = None
|
auth: MailAuth | None = None
|
||||||
addresses: List[EmailStr]
|
addresses: List[EmailStr]
|
||||||
|
|
||||||
|
|
||||||
|
@ -179,31 +189,40 @@ class General(BaseModel):
|
||||||
env: Environment = "production"
|
env: Environment = "production"
|
||||||
cookie_secret: str
|
cookie_secret: str
|
||||||
session_duration: int = 10080 # 7 days
|
session_duration: int = 10080 # 7 days
|
||||||
remember_me_duration: Optional[int] = None
|
remember_me_duration: int | None = None
|
||||||
frequency: int
|
frequency: float
|
||||||
|
recheck_delay: float | None = None
|
||||||
root_path: str = ""
|
root_path: str = ""
|
||||||
alerts: Alert
|
alerts: Alert
|
||||||
mail: Optional[Mail] = None
|
mail: Mail | None = None
|
||||||
gotify: Optional[List[GotifyUrl]] = None
|
gotify: List[GotifyUrl] | None = None
|
||||||
apprise: Optional[Dict[str, List[str]]] = None
|
apprise: Dict[str, List[str]] | None = None
|
||||||
|
|
||||||
@field_validator("session_duration", mode="before")
|
@field_validator("session_duration", mode="before")
|
||||||
def parse_session_duration(cls, value):
|
def parse_session_duration(cls, value):
|
||||||
"""Convert the configured session duration to minutes"""
|
"""Convert the configured session duration to minutes"""
|
||||||
return string_to_duration(value, "minutes")
|
return Duration(value).to_minutes()
|
||||||
|
|
||||||
@field_validator("remember_me_duration", mode="before")
|
@field_validator("remember_me_duration", mode="before")
|
||||||
def parse_remember_me_duration(cls, value):
|
def parse_remember_me_duration(cls, value):
|
||||||
"""Convert the configured session duration with remember me feature to minutes"""
|
"""Convert the configured session duration with remember me feature to minutes"""
|
||||||
if value:
|
if value:
|
||||||
return string_to_duration(value, "minutes")
|
return int(Duration(value).to_minutes())
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@field_validator("frequency", mode="before")
|
@field_validator("frequency", mode="before")
|
||||||
def parse_frequency(cls, value):
|
def parse_frequency(cls, value):
|
||||||
"""Convert the configured frequency to minutes"""
|
"""Convert the configured frequency to minutes"""
|
||||||
return string_to_duration(value, "minutes")
|
return Duration(value).to_minutes()
|
||||||
|
|
||||||
|
@field_validator("recheck_delay", mode="before")
|
||||||
|
def parse_recheck_delay(cls, value):
|
||||||
|
"""Convert the configured recheck delay to minutes"""
|
||||||
|
if value:
|
||||||
|
return Duration(value).to_minutes()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseModel):
|
class Config(BaseModel):
|
||||||
|
|
|
@ -4,44 +4,3 @@ from typing import Literal
|
||||||
Method = Literal[
|
Method = Literal[
|
||||||
"GET", "HEAD", "POST", "OPTIONS", "CONNECT", "TRACE", "PUT", "PATCH", "DELETE"
|
"GET", "HEAD", "POST", "OPTIONS", "CONNECT", "TRACE", "PUT", "PATCH", "DELETE"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def string_to_duration(
|
|
||||||
value: str, target: Literal["days", "hours", "minutes"]
|
|
||||||
) -> int | float:
|
|
||||||
"""Convert a string to a number of hours, days or minutes"""
|
|
||||||
num = int("".join(filter(str.isdigit, value)))
|
|
||||||
|
|
||||||
# It's not possible to convert from a smaller unit to a greater one:
|
|
||||||
# - hours and minutes cannot be converted to days
|
|
||||||
# - minutes cannot be converted to hours
|
|
||||||
if (target == "days" and ("h" in value or "m" in value.replace("mo", ""))) or (
|
|
||||||
target == "hours" and "m" in value.replace("mo", "")
|
|
||||||
):
|
|
||||||
msg = (
|
|
||||||
"Durations cannot be converted from a smaller to a greater unit. "
|
|
||||||
f"(trying to convert '{value}' to {target})"
|
|
||||||
)
|
|
||||||
raise ValueError(msg, value)
|
|
||||||
|
|
||||||
# Consider we're converting to minutes, do the eventual multiplication at the end.
|
|
||||||
if "h" in value:
|
|
||||||
num = num * 60
|
|
||||||
elif "d" in value:
|
|
||||||
num = num * 60 * 24
|
|
||||||
elif "w" in value:
|
|
||||||
num = num * 60 * 24 * 7
|
|
||||||
elif "mo" in value:
|
|
||||||
num = num * 60 * 24 * 30 # considers 30d in a month
|
|
||||||
elif "y" in value:
|
|
||||||
num = num * 60 * 24 * 365 # considers 365d in a year
|
|
||||||
elif "m" not in value:
|
|
||||||
raise ValueError("Invalid duration value", value)
|
|
||||||
|
|
||||||
if target == "hours":
|
|
||||||
return num / 60
|
|
||||||
if target == "days":
|
|
||||||
return num / 60 / 24
|
|
||||||
|
|
||||||
# target == "minutes"
|
|
||||||
return num
|
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
"""Add recheck delay
|
||||||
|
|
||||||
|
Revision ID: 127d74c770bb
|
||||||
|
Revises: dcf73fa19fce
|
||||||
|
Create Date: 2024-11-27 16:04:58.138768
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "127d74c770bb"
|
||||||
|
down_revision: Union[str, None] = "dcf73fa19fce"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("tasks", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("recheck_delay", sa.Float(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("already_retried", sa.Boolean(), nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("tasks", schema=None) as batch_op:
|
||||||
|
batch_op.drop_column("already_retried")
|
||||||
|
batch_op.drop_column("recheck_delay")
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Make frequency a float
|
||||||
|
|
||||||
|
Revision ID: a1e98cf72a5c
|
||||||
|
Revises: 127d74c770bb
|
||||||
|
Create Date: 2024-11-27 16:10:13.000705
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a1e98cf72a5c"
|
||||||
|
down_revision: Union[str, None] = "127d74c770bb"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("tasks", schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"frequency",
|
||||||
|
existing_type=sa.INTEGER(),
|
||||||
|
type_=sa.Float(),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("tasks", schema=None) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"frequency",
|
||||||
|
existing_type=sa.Float(),
|
||||||
|
type_=sa.INTEGER(),
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
|
@ -35,7 +35,9 @@ class Task(Base):
|
||||||
domain: Mapped[str] = mapped_column()
|
domain: Mapped[str] = mapped_column()
|
||||||
check: Mapped[str] = mapped_column()
|
check: Mapped[str] = mapped_column()
|
||||||
expected: Mapped[str] = mapped_column()
|
expected: Mapped[str] = mapped_column()
|
||||||
frequency: Mapped[int] = mapped_column()
|
frequency: Mapped[float] = mapped_column()
|
||||||
|
recheck_delay: Mapped[float] = mapped_column(nullable=True)
|
||||||
|
already_retried: Mapped[bool] = mapped_column(insert_default=False)
|
||||||
method: Mapped[Method] = mapped_column(
|
method: Mapped[Method] = mapped_column(
|
||||||
Enum(
|
Enum(
|
||||||
"GET",
|
"GET",
|
||||||
|
@ -86,7 +88,16 @@ class Task(Base):
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
self.completed_at = now
|
self.completed_at = now
|
||||||
self.next_run = now + timedelta(minutes=self.frequency)
|
if (
|
||||||
|
self.recheck_delay is not None
|
||||||
|
and severity != "ok"
|
||||||
|
and not self.already_retried
|
||||||
|
):
|
||||||
|
self.next_run = now + timedelta(minutes=self.recheck_delay)
|
||||||
|
self.already_retried = True
|
||||||
|
else:
|
||||||
|
self.next_run = now + timedelta(minutes=self.frequency)
|
||||||
|
self.already_retried = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_result(self):
|
def last_result(self):
|
||||||
|
|
|
@ -100,6 +100,11 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
|
||||||
same_config = False
|
same_config = False
|
||||||
conf.val = str(config.general.frequency)
|
conf.val = str(config.general.frequency)
|
||||||
conf.updated_at = datetime.now()
|
conf.updated_at = datetime.now()
|
||||||
|
case "general_recheck_delay":
|
||||||
|
if conf.val != str(config.general.recheck_delay):
|
||||||
|
same_config = False
|
||||||
|
conf.val = str(config.general.recheck_delay)
|
||||||
|
conf.updated_at = datetime.now()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
@ -115,8 +120,14 @@ async def has_config_changed(db: Session, config: schemas.Config) -> bool:
|
||||||
val=str(config.general.frequency),
|
val=str(config.general.frequency),
|
||||||
updated_at=datetime.now(),
|
updated_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
gen_recheck = ConfigCache(
|
||||||
|
name="general_recheck_delay",
|
||||||
|
val=str(config.general.recheck_delay),
|
||||||
|
updated_at=datetime.now(),
|
||||||
|
)
|
||||||
db.add(web_hash)
|
db.add(web_hash)
|
||||||
db.add(gen_freq)
|
db.add(gen_freq)
|
||||||
|
db.add(gen_recheck)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -137,6 +148,7 @@ async def update_from_config(db: Session, config: schemas.Config):
|
||||||
for website in config.websites:
|
for website in config.websites:
|
||||||
domain = str(website.domain)
|
domain = str(website.domain)
|
||||||
frequency = website.frequency or config.general.frequency
|
frequency = website.frequency or config.general.frequency
|
||||||
|
recheck_delay = website.recheck_delay or config.general.recheck_delay
|
||||||
|
|
||||||
for p in website.paths:
|
for p in website.paths:
|
||||||
url = urljoin(domain, str(p.path))
|
url = urljoin(domain, str(p.path))
|
||||||
|
@ -158,15 +170,18 @@ async def update_from_config(db: Session, config: schemas.Config):
|
||||||
|
|
||||||
if frequency != existing_task.frequency:
|
if frequency != existing_task.frequency:
|
||||||
existing_task.frequency = frequency
|
existing_task.frequency = frequency
|
||||||
|
if recheck_delay != existing_task.recheck_delay:
|
||||||
|
existing_task.recheck_delay = recheck_delay # type: ignore[assignment]
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Skipping db task creation for url=%s, "
|
"Skipping db task creation for url=%s, "
|
||||||
"method=%s, check_key=%s, expected=%s, "
|
"method=%s, check_key=%s, expected=%s, "
|
||||||
"frequency=%s.",
|
"frequency=%s, recheck_delay=%s.",
|
||||||
url,
|
url,
|
||||||
p.method,
|
p.method,
|
||||||
check_key,
|
check_key,
|
||||||
expected,
|
expected,
|
||||||
frequency,
|
frequency,
|
||||||
|
recheck_delay,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -180,6 +195,8 @@ async def update_from_config(db: Session, config: schemas.Config):
|
||||||
check=check_key,
|
check=check_key,
|
||||||
expected=expected,
|
expected=expected,
|
||||||
frequency=frequency,
|
frequency=frequency,
|
||||||
|
recheck_delay=recheck_delay,
|
||||||
|
already_retried=False,
|
||||||
)
|
)
|
||||||
logger.debug("Adding a new task in the db: %s", task)
|
logger.debug("Adding a new task in the db: %s", task)
|
||||||
tasks.append(task)
|
tasks.append(task)
|
||||||
|
|
|
@ -25,6 +25,7 @@ dependencies = [
|
||||||
"apprise>=1.9.0,<2",
|
"apprise>=1.9.0,<2",
|
||||||
"bcrypt>=4.1.3,<5",
|
"bcrypt>=4.1.3,<5",
|
||||||
"click>=8.1,<9",
|
"click>=8.1,<9",
|
||||||
|
"durations-nlp>=1.0.1,<2",
|
||||||
"fastapi>=0.103,<0.104",
|
"fastapi>=0.103,<0.104",
|
||||||
"fastapi-login>=1.10.0,<2",
|
"fastapi-login>=1.10.0,<2",
|
||||||
"httpx>=0.27.2,<1",
|
"httpx>=0.27.2,<1",
|
||||||
|
|
|
@ -1,51 +0,0 @@
|
||||||
import pytest
|
|
||||||
|
|
||||||
from argos.schemas.utils import string_to_duration
|
|
||||||
|
|
||||||
|
|
||||||
def test_string_to_duration_days():
|
|
||||||
assert string_to_duration("1d", target="days") == 1
|
|
||||||
assert string_to_duration("1w", target="days") == 7
|
|
||||||
assert string_to_duration("3w", target="days") == 21
|
|
||||||
assert string_to_duration("3mo", target="days") == 90
|
|
||||||
assert string_to_duration("1y", target="days") == 365
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
string_to_duration("3h", target="days")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
string_to_duration("1", target="days")
|
|
||||||
|
|
||||||
|
|
||||||
def test_string_to_duration_hours():
|
|
||||||
assert string_to_duration("1h", target="hours") == 1
|
|
||||||
assert string_to_duration("1d", target="hours") == 24
|
|
||||||
assert string_to_duration("1w", target="hours") == 7 * 24
|
|
||||||
assert string_to_duration("3w", target="hours") == 21 * 24
|
|
||||||
assert string_to_duration("3mo", target="hours") == 3 * 30 * 24
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
string_to_duration("1", target="hours")
|
|
||||||
|
|
||||||
|
|
||||||
def test_string_to_duration_minutes():
|
|
||||||
assert string_to_duration("1m", target="minutes") == 1
|
|
||||||
assert string_to_duration("1h", target="minutes") == 60
|
|
||||||
assert string_to_duration("1d", target="minutes") == 60 * 24
|
|
||||||
assert string_to_duration("3mo", target="minutes") == 60 * 24 * 30 * 3
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
string_to_duration("1", target="minutes")
|
|
||||||
|
|
||||||
|
|
||||||
def test_conversion_to_greater_units_throws():
|
|
||||||
# hours and minutes cannot be converted to days
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
string_to_duration("1h", target="days")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
string_to_duration("1m", target="days")
|
|
||||||
|
|
||||||
# minutes cannot be converted to hours
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
string_to_duration("1m", target="hours")
|
|
Loading…
Reference in a new issue