Skip to content

Commit b865491

Browse files
authored
colleted bug fixes (#148)
* remove unnecessary headers * remove doc submodule as its buggy * re-add docs submodule * change FSM state only when necessary * set thing model handler method to GET * fix enum access in state machine only if state is enum * remove assert Access Control Allow Credentials in tests * use uuid_hex from utils * clean up stuff based on logs * add a name to security schemes and remove password deletion * refactor security scheme enforcement on server * add a security scheme class for client * refactor HTTP basic auth * ruf tests folder * add ruff tests folder to pipeline * add an untested precommit file * test precommit * update changelog * add SAST comments
1 parent c1f4d94 commit b865491

34 files changed

+400
-257
lines changed

.github/workflows/ci-pipeline.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ jobs:
2626
- name: install ruff
2727
run: pip install ruff
2828

29-
- name: run ruff linter
29+
- name: run ruff linter src directory
3030
run: ruff check hololinked
3131

32+
- name: run ruff linter tests directory
33+
run: ruff check tests/*.py tests/things/*.py tests/helper-scripts/*.py
34+
3235
scan:
3336
name: security scan (${{ matrix.tool }})
3437
runs-on: ubuntu-latest

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ tests/run-unittest.bat
1919
# vs-code
2020
.vscode/launch.json
2121
.vs/
22+
todo
2223

2324
# zmq
2425
*.ipc

.gitmodules

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[submodule "examples"]
22
path = examples
33
url = https://github.com/hololinked-dev/examples.git
4-
[submodule "doc"]
5-
path = doc
6-
url = https://github.com/hololinked-dev/docs-v2.git
4+
[submodule "docs"]
5+
path = docs
6+
url = https://github.com/hololinked-dev/docs.git

.pre-commit-config.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
repos:
2+
- repo: https://github.com/astral-sh/ruff-pre-commit
3+
rev: v0.14.7
4+
hooks:
5+
- id: ruff-check
6+
files: ^hololinked/
7+
8+
- repo: https://github.com/PyCQA/bandit
9+
rev: "1.9.2"
10+
hooks:
11+
- id: bandit
12+
# needs to be fixed
13+
args: ["pyproject.toml -r hololinked/ -b .bandit-baseline.json"]
14+
pass_filenames: false
15+
16+
- repo: https://github.com/gitleaks/gitleaks
17+
rev: v8.26.0
18+
hooks:
19+
- id: gitleaks
20+
entry: gitleaks git --pre-commit --redact --staged --verbose
21+
pass_filenames: false

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1313
- supports structlog for logging, with colored logs and updated log statements
1414
- SAST with bandit & gitleaks integrated into CI/CD pipelines
1515
- uses pytest instead of unittests
16-
- code improvements with isort, dependency refactoring etc.
16+
- code improvements with isort, refactors & optimizations etc.
17+
- fixes minor bugs & cleans up HTTP headers
1718

1819
## [v0.3.7] - 2025-10-30
1920

doc

Lines changed: 0 additions & 1 deletion
This file was deleted.

docs

Submodule docs added at e8ec16a

hololinked/client/factory.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import base64
21
import ssl
32
import threading
4-
import uuid
53
import warnings
64

75
from typing import Any
@@ -22,11 +20,12 @@
2220
EventAffordance,
2321
PropertyAffordance,
2422
)
25-
from ..utils import set_global_event_loop_policy
23+
from ..utils import set_global_event_loop_policy, uuid_hex
2624
from .abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
2725
from .http.consumed_interactions import HTTPAction, HTTPEvent, HTTPProperty
2826
from .mqtt.consumed_interactions import MQTTConsumer # only one type for now
2927
from .proxy import ObjectProxy
28+
from .security import BasicSecurity
3029
from .zmq.consumed_interactions import (
3130
ReadMultipleProperties,
3231
WriteMultipleProperties,
@@ -71,8 +70,6 @@ def zmq(
7170
7271
- `logger`: `logging.Logger`, optional.
7372
A custom logger instance to use for logging
74-
- `log_level`: `int`, default `logging.INFO`.
75-
The logging level to use for the client (e.g., logging.DEBUG, logging.INFO)
7673
- `ignore_TD_errors`: `bool`, default `False`.
7774
Whether to ignore errors while fetching the Thing Description (TD)
7875
- `skip_interaction_affordances`: `list[str]`, default `[]`.
@@ -87,7 +84,7 @@ def zmq(
8784
ObjectProxy
8885
An ObjectProxy instance representing the remote Thing
8986
"""
90-
id = f"{server_id}|{thing_id}|{access_point}|{uuid.uuid4()}"
87+
id = kwargs.get("id", f"{server_id}|{thing_id}|{access_point}|{uuid_hex()}")
9188

9289
# configs
9390
ignore_TD_errors = kwargs.get("ignore_TD_errors", False)
@@ -131,6 +128,7 @@ def zmq(
131128
logger=logger,
132129
invokation_timeout=invokation_timeout,
133130
execution_timeout=execution_timeout,
131+
security=kwargs.get("security", None),
134132
)
135133

136134
# add properties
@@ -278,24 +276,26 @@ def http(self, url: str, **kwargs) -> ObjectProxy:
278276

279277
# fetch TD
280278
headers = {"Content-Type": "application/json"}
281-
username = kwargs.get("username")
282-
password = kwargs.get("password")
283-
if username and password:
284-
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
285-
headers["Authorization"] = f"Basic {token}"
279+
security = kwargs.pop("security", None)
280+
username = kwargs.pop("username", None)
281+
password = kwargs.pop("password", None)
282+
if not security and username and password:
283+
security = BasicSecurity(username=username, password=password)
284+
if isinstance(security, BasicSecurity):
285+
headers["Authorization"] = security.http_header
286286

287287
response = req_rep_sync_client.get(url, headers=headers) # type: httpx.Response
288288
response.raise_for_status()
289289

290290
TD = Serializers.json.loads(response.content)
291-
id = f"client|{TD['id']}|HTTP|{uuid.uuid4().hex[:8]}"
291+
id = kwargs.get("id", f"client|{TD['id']}|HTTP|{uuid_hex()}")
292292
logger = kwargs.get("logger", structlog.get_logger()).bind(
293293
component="client",
294294
client_id=id,
295295
protocol="http",
296296
thing_id=TD["id"],
297297
)
298-
object_proxy = ObjectProxy(id, td=TD, logger=logger, **kwargs)
298+
object_proxy = ObjectProxy(id, td=TD, logger=logger, security=security, **kwargs)
299299

300300
for name in TD.get("properties", []):
301301
affordance = PropertyAffordance.from_TD(name, TD)
@@ -383,7 +383,7 @@ def mqtt(
383383
- `log_level`: `int`, default `logging.INFO`.
384384
The logging level to use for the client (e.g., logging.DEBUG, logging.INFO
385385
"""
386-
id = f"mqtt-client|{hostname}:{port}|{uuid.uuid4().hex[:8]}"
386+
id = kwargs.get("id", f"mqtt-client|{hostname}:{port}|{uuid_hex()}")
387387
logger = kwargs.get("logger", structlog.get_logger()).bind(
388388
component="client",
389389
client_id=id,

hololinked/client/http/consumed_interactions.py

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -66,32 +66,13 @@ def get_body_from_response(
6666
return body
6767
response.raise_for_status()
6868

69-
def _merge_auth_headers(self, base: dict | None = None):
70-
headers = dict(base or {})
71-
72-
# Avoid truthiness on ObjectProxy
73-
owner = getattr(self, "_owner_inst", None)
74-
if owner is None:
75-
owner = getattr(self, "owner", None)
76-
77-
auth = getattr(owner, "_auth_header", None) if owner is not None else None
78-
79-
# Normalize present header names (case-insensitive)
80-
present = {k.lower() for k in headers}
81-
82-
if auth:
83-
if isinstance(auth, dict):
84-
# Merge key-by-key if caller stored a header dict
85-
for k, v in auth.items():
86-
if k.lower() not in present:
87-
headers[k] = v
88-
elif isinstance(auth, str):
89-
# Caller stored just the value: "Basic abcd=="
90-
if "authorization" not in present:
91-
headers["Authorization"] = auth
92-
else:
93-
# Ignore unexpected types instead of crashing
94-
pass
69+
def _merge_auth_headers(self, base: dict[str, str]) -> dict[str, str]:
70+
headers = base or {}
71+
72+
if not self.owner_inst or self.owner_inst._security is None:
73+
return headers
74+
if not any(key.lower() == "authorization" for key in headers.keys()):
75+
headers["Authorization"] = self.owner_inst._security.http_header
9576

9677
return headers
9778

@@ -347,7 +328,9 @@ def listen(self, form: Form, callbacks: list[Callable], concurrent: bool = False
347328

348329
try:
349330
with self._sync_http_client.stream(
350-
method="GET", url=form.href, headers=self._merge_auth_headers({"Accept": "text/event-stream"})
331+
method="GET",
332+
url=form.href,
333+
headers=self._merge_auth_headers({"Accept": "text/event-stream"}),
351334
) as resp:
352335
resp.raise_for_status()
353336
interrupting_event = threading.Event()
@@ -383,7 +366,9 @@ async def async_listen(
383366

384367
try:
385368
async with self._async_http_client.stream(
386-
method="GET", url=form.href, headers=self._merge_auth_headers({"Accept": "text/event-stream"})
369+
method="GET",
370+
url=form.href,
371+
headers=self._merge_auth_headers({"Accept": "text/event-stream"}),
387372
) as resp:
388373
resp.raise_for_status()
389374
interrupting_event = asyncio.Event()

hololinked/client/proxy.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import base64
2-
31
from typing import Any, Callable
42

53
import structlog
@@ -29,7 +27,7 @@ class ObjectProxy:
2927
"_events",
3028
"_noblock_messages",
3129
"_schema_validator",
32-
"_auth_header",
30+
"_security",
3331
]
3432
)
3533

@@ -59,16 +57,10 @@ def __init__(self, id: str, **kwargs) -> None:
5957
self._allow_foreign_attributes = kwargs.get("allow_foreign_attributes", False)
6058
self._noblock_messages = dict() # type: dict[str, ConsumedThingAction | ConsumedThingProperty]
6159
self._schema_validator = kwargs.get("schema_validator", None)
60+
self._security = kwargs.get("security", None)
6261
self.logger = kwargs.pop("logger", structlog.get_logger())
6362
self.td = kwargs.get("td", dict()) # type: dict[str, Any]
6463

65-
self._auth_header = None
66-
username = kwargs.get("username")
67-
password = kwargs.get("password")
68-
if username and password:
69-
token = f"{username}:{password}".encode("utf-8")
70-
self._auth_header = {"Authorization": f"Basic {base64.b64encode(token).decode('utf-8')}"}
71-
7264
def __getattribute__(self, __name: str) -> Any:
7365
obj = super().__getattribute__(__name)
7466
if isinstance(obj, ConsumedThingProperty):

0 commit comments

Comments
 (0)