Skip to content

Commit 9ece527

Browse files
committed
2.0.7 (2025-09-14)
------------------ **Changed:** f-strings to more robustly helpers.url_join()
1 parent 56e30df commit 9ece527

File tree

11 files changed

+139
-82
lines changed

11 files changed

+139
-82
lines changed

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ Unreleased
1313
**New:** `FortiGateAPI.monitor` connectors, to work with all `Monitor API` endpoints.
1414

1515

16+
2.0.7 (2025-09-14)
17+
------------------
18+
19+
**Changed:** f-strings to more robustly helpers.url_join()
20+
21+
**Added:** _init_host() Init host: valid hostname or valid IPv4/IPv6 address.
22+
23+
1624
2.0.6 (2025-05-17)
1725
------------------
1826

docs/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
project = "fortigate-api"
99
copyright = "2021, Vladimirs Prusakovs"
1010
author = "Vladimirs Prusakovs"
11-
release = "2.0.6"
11+
release = "2.0.7"
1212

1313
extensions = [
1414
"sphinx.ext.autodoc",

fortigate_api/cmdb/firewall/policy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ def move(self, policyid: StrInt, position: str, neighbor: StrInt) -> Response:
5858
# "secretkey": self.fortigate.password,
5959
position: neighbor,
6060
}
61-
url = f"{self.url}/{policyid}"
61+
url = h.url_join(self.url, policyid)
6262
url = h.join_url_params(url, **params)
6363
return self.fortigate.put(url=url, data={})

fortigate_api/connector.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def __init__(self, fortigate: FortiGate, **kwargs):
3131
@property
3232
def url(self) -> str:
3333
"""URL to the fortigate-object."""
34-
return f"{self.fortigate.url}/{self._path}"
34+
return h.url_join(self.fortigate.url, self._path)
3535

3636
def create(self, data: DAny) -> Response:
3737
"""Create the fortigate-object in the Fortigate.
@@ -79,7 +79,7 @@ def delete(
7979
if filter:
8080
return self._delete_by_filter(filter)
8181
uid = h.quote(uid)
82-
url = f"{self.url}/{uid}"
82+
url = h.url_join(self.url, uid)
8383
return self.fortigate.delete(url=url)
8484

8585
def get(self, **kwargs) -> LDAny:
@@ -93,7 +93,7 @@ def get(self, **kwargs) -> LDAny:
9393
:rtype: List[dict]
9494
"""
9595
uid = h.quote(vdict.pop(kwargs, key=self.uid))
96-
url = f"{self.url}/{uid}".rstrip("/")
96+
url = h.url_join(self.url, uid)
9797
url = h.join_url_params(url=url, **kwargs)
9898
if self.uid:
9999
items: LDAny = self.fortigate.get_results(url)
@@ -114,7 +114,7 @@ def is_exist(self, uid: StrInt) -> bool:
114114
uid = h.quote(uid)
115115
if not uid:
116116
raise ValueError("uid is required.")
117-
url = f"{self.url}/{uid}"
117+
url = h.url_join(self.url, uid)
118118
response = self.fortigate.exist(url)
119119
return response.ok
120120

@@ -131,7 +131,7 @@ def update(self, data: DAny) -> Response:
131131
:rtype: Response
132132
"""
133133
uid: str = self._get_uid(data)
134-
url = f"{self.url}/{uid}".rstrip("/")
134+
url = h.url_join(self.url, uid)
135135
return self.fortigate.put(url=url, data=data)
136136

137137
# noinspection PyShadowingBuiltins
@@ -153,7 +153,7 @@ def _delete_by_filter(self, filter: UStr) -> Response: # pylint: disable=redefi
153153
items: LDAny = self.get(filter=filters)
154154
for data in items:
155155
uid = h.quote(data[self.uid])
156-
url = f"{self.url}/{uid}"
156+
url = h.url_join(self.url, uid)
157157
response = self.fortigate.delete(url=url)
158158
responses.append(response)
159159
return h.highest_response(responses)
@@ -168,7 +168,7 @@ def _get_uid(self, data) -> str:
168168
if not self.uid:
169169
return ""
170170
if self.uid not in data:
171-
raise ValueError(f"{self.uid} value is required.")
171+
raise ValueError(f"uid={self.uid!r} value is required in data.")
172172
uid = str(data[self.uid])
173173
uid = h.quote(uid)
174174
return uid

fortigate_api/extended_filters.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from functools import wraps
66
from ipaddress import ip_network, IPv4Network
77
from typing import Tuple
8+
from urllib.parse import urljoin
89

910
from vhelpers import vre
1011

@@ -62,8 +63,10 @@ def efilter_by_sdst(policies: LDAny, efilter: str, connector) -> None:
6263
return
6364
# get addresses and address-groups from the Fortigate
6465
fortigate = connector.fortigate
65-
addresses: LDAny = fortigate.get_results(url=f"{fortigate.url}/api/v2/cmdb/firewall/address")
66-
addr_groups: LDAny = fortigate.get_results(url=f"{fortigate.url}/api/v2/cmdb/firewall/addrgrp")
66+
url = urljoin(fortigate.url, "/api/v2/cmdb/firewall/address")
67+
addresses: LDAny = fortigate.get_results(url)
68+
url = urljoin(fortigate.url, "/api/v2/cmdb/firewall/addrgrp")
69+
addr_groups: LDAny = fortigate.get_results(url)
6770
names_subnets_d: DLStr = _get_names_subnets(addresses, addr_groups)
6871
names_ipnets_d: DLInet = _convert_subnets_to_ipnets(names_subnets_d)
6972

fortigate_api/fortigate_base.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
from __future__ import annotations
44

5+
import ipaddress
56
import json
67
import logging
7-
import re
88
from typing import Callable, Iterable, Optional
9-
from urllib.parse import urlencode, urljoin
9+
from urllib.parse import urlencode, urljoin, urlunparse
1010

1111
import requests
1212
from requests import Session, Response
@@ -19,6 +19,7 @@
1919
# noinspection PyUnresolvedReferences
2020
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
2121

22+
HTTP = "http"
2223
HTTPS = "https"
2324
PORT_443 = 443
2425
PORT_80 = 80
@@ -59,7 +60,7 @@ def __init__(self, **kwargs):
5960
:param bool logging_error: Logging only the REST API response with error.
6061
`True` - Enable errors logging, `False` - otherwise. Default is `False`.
6162
"""
62-
self.host = str(kwargs.get("host"))
63+
self.host = _init_host(**kwargs)
6364
self.username = str(kwargs.get("username"))
6465
self.password = str(kwargs.get("password"))
6566
self.token = _init_token(**kwargs)
@@ -81,7 +82,7 @@ def __repr__(self):
8182
host = self.host
8283
username = self.username
8384
scheme = self.scheme
84-
port = self.port if not (scheme == HTTPS and self.port == PORT_443) else ""
85+
port = self.port
8586
timeout = self.timeout
8687
verify = self.verify
8788
vdom = self.vdom
@@ -123,11 +124,8 @@ def is_connected(self) -> bool:
123124
@property
124125
def url(self) -> str:
125126
"""Return URL to the Fortigate."""
126-
if self.scheme == HTTPS and self.port == 443:
127-
return f"{self.scheme}://{self.host}"
128-
if self.scheme == "http" and self.port == 80:
129-
return f"{self.scheme}://{self.host}"
130-
return f"{self.scheme}://{self.host}:{self.port}"
127+
components = (self.scheme, f"{self.host}:{self.port}", "/", "", "", "")
128+
return urlunparse(components)
131129

132130
# ============================ login =============================
133131

@@ -145,7 +143,7 @@ def login(self) -> None:
145143
if self.token:
146144
try:
147145
response: Response = session.get(
148-
url=f"{self.url}/api/v2/monitor/system/status",
146+
url=urljoin(self.url, "/api/v2/monitor/system/status"),
149147
headers=self._bearer_token(),
150148
verify=self.verify,
151149
)
@@ -158,7 +156,7 @@ def login(self) -> None:
158156
# password
159157
try:
160158
response = session.post(
161-
url=f"{self.url}/logincheck",
159+
url=urljoin(self.url, "/logincheck"),
162160
data=urlencode([("username", self.username), ("secretkey", self.password)]),
163161
timeout=self.timeout,
164162
verify=self.verify,
@@ -182,13 +180,12 @@ def logout(self) -> None:
182180
if not self.token:
183181
try:
184182
self._session.get(
185-
url=f"{self.url}/logout",
183+
url=urljoin(self.url, "/logout"),
186184
timeout=self.timeout,
187185
verify=self.verify,
188186
)
189187
except SSLError:
190188
pass
191-
del self._session
192189
self._session = None
193190

194191
# =========================== helpers ============================
@@ -251,7 +248,7 @@ def _init_port(self, **kwargs) -> int:
251248
"""Init port, 443 for scheme=`https`, 80 for scheme=`http`."""
252249
if port := int(kwargs.get("port") or 0):
253250
return port
254-
if self.scheme == "http":
251+
if self.scheme == HTTP:
255252
return PORT_80
256253
return PORT_443
257254

@@ -281,7 +278,7 @@ def _response(self, method: Method, url: str, data: ODAny = None) -> Response:
281278
:rtype: Response
282279
"""
283280
params: DAny = {
284-
"url": self._valid_url(url),
281+
"url": urljoin(self.url, url),
285282
"params": urlencode([("vdom", self.vdom)]),
286283
"timeout": self.timeout,
287284
"verify": self.verify,
@@ -299,26 +296,37 @@ def _response(self, method: Method, url: str, data: ODAny = None) -> Response:
299296
raise self._hide_secret_ex(ex)
300297
return response
301298

302-
def _valid_url(self, url: str) -> str:
303-
"""Return a valid URL string.
304299

305-
Add `https://` to `url` if it is absent and remove trailing `/` character.
306-
"""
307-
if re.match("http(s)?://", url):
308-
return url.rstrip("/")
309-
path = url.strip("/")
310-
return urljoin(self.url, path)
300+
# =========================== helpers ============================
311301

312302

313-
# =========================== helpers ============================
303+
def _init_host(**kwargs) -> str:
304+
"""Init host: valid hostname or valid IPv4/IPv6 address."""
305+
host = str(kwargs.get("host", "")).strip()
306+
if not host:
307+
raise ValueError(f"{host=!r}, hostname is not specified.")
308+
309+
# Strip brackets if provided (IPv6 URL style)
310+
if host.startswith("[") and host.endswith("]"):
311+
host = host[1:-1]
312+
313+
# Try IP validation first
314+
try:
315+
ip = ipaddress.ip_address(host)
316+
if isinstance(ip, ipaddress.IPv6Address):
317+
host = f"[{host}]"
318+
except ValueError:
319+
pass # hostname
320+
321+
return host
314322

315323

316324
def _init_scheme(**kwargs) -> str:
317325
"""Init scheme `https` or `http`."""
318326
scheme = str(kwargs.get("scheme") or HTTPS)
319-
expected = ["https", "http"]
327+
expected = [HTTPS, HTTP]
320328
if scheme not in expected:
321-
raise ValueError(f"{scheme=}, {expected=}.")
329+
raise ValueError(f"{scheme=!r}, {expected=!r}.")
322330
return scheme
323331

324332

@@ -328,7 +336,7 @@ def _init_token(**kwargs) -> str:
328336
if not token:
329337
return ""
330338
if kwargs.get("username"):
331-
raise ValueError("Mutually excluded: username, token.")
339+
raise ValueError("A username and a token are mutually exclusive.")
332340
if kwargs.get("password"):
333-
raise ValueError("Mutually excluded: password, token.")
341+
raise ValueError("A password and a token are mutually exclusive.")
334342
return token

fortigate_api/helpers.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,9 @@ def join_url_params(url: str, **params) -> str:
251251
return: "https://fomain.com?a=a&b=b&b=B"
252252
"""
253253
url_o: ParseResult = urlparse(url)
254-
params_or: DAny = parse_qs(url_o.query)
255-
params_: DAny = {**params_or, **params}
256-
query: str = urlencode(params_, doseq=True)
254+
params_old: DAny = parse_qs(url_o.query)
255+
params_new: DAny = {**params_old, **params}
256+
query: str = urlencode(params_new, doseq=True)
257257
url_o = url_o._replace(query=query)
258258
return url_o.geturl()
259259

@@ -270,6 +270,14 @@ def quote(string: Any) -> str:
270270
return parse.quote(string=string, safe="")
271271

272272

273+
def url_join(base: str, path: StrInt) -> str:
274+
"""Join path segments to a base URL safely using slash."""
275+
path_ = str(path)
276+
if not path_:
277+
return base
278+
return base.rstrip("/") + "/" + path_.lstrip("/")
279+
280+
273281
def url_to_app_model(url: str) -> str:
274282
"""Parse app/model name from the URL.
275283

fortigate_api/schema/custom/cmdb/firewall/policy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,6 @@ def move(self, policyid: StrInt, position: str, neighbor: StrInt) -> Response:
5858
# "secretkey": self.fortigate.password,
5959
position: neighbor,
6060
}
61-
url = f"{self.url}/{policyid}"
61+
url = h.url_join(self.url, policyid)
6262
url = h.join_url_params(url, **params)
6363
return self.fortigate.put(url=url, data={})

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "fortigate_api"
3-
version = "2.0.6"
3+
version = "2.0.7"
44
description = "Python package to configure Fortigate (Fortios) devices using REST API and SSH"
55
authors = ["Vladimirs Prusakovs <[email protected]>"]
66
readme = "README.rst"

0 commit comments

Comments
 (0)