Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci-pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ jobs:
- name: install ruff
run: pip install ruff

- name: run ruff linter
- name: run ruff linter src directory
run: ruff check hololinked

- name: run ruff linter tests directory
run: ruff check tests/*.py tests/things/*.py tests/helper-scripts/*.py

scan:
name: security scan (${{ matrix.tool }})
runs-on: ubuntu-latest
Expand Down
6 changes: 3 additions & 3 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[submodule "examples"]
path = examples
url = https://github.com/hololinked-dev/examples.git
[submodule "doc"]
path = doc
url = https://github.com/hololinked-dev/docs-v2.git
[submodule "docs"]
path = docs
url = https://github.com/hololinked-dev/docs.git
21 changes: 21 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.7
hooks:
- id: ruff-check
files: ^hololinked/

- repo: https://github.com/PyCQA/bandit
rev: "1.9.2"
hooks:
- id: bandit
# needs to be fixed
args: ["pyproject.toml -r hololinked/ -b .bandit-baseline.json"]
pass_filenames: false

- repo: https://github.com/gitleaks/gitleaks
rev: v8.26.0
hooks:
- id: gitleaks
entry: gitleaks git --pre-commit --redact --staged --verbose
pass_filenames: false
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- supports structlog for logging, with colored logs and updated log statements
- SAST with bandit & gitleaks integrated into CI/CD pipelines
- uses pytest instead of unittests
- code improvements with isort, dependency refactoring etc.
- code improvements with isort, refactors & optimizations etc.
- fixes minor bugs & cleans up HTTP headers

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

Expand Down
1 change: 0 additions & 1 deletion doc
Submodule doc deleted from d4e965
1 change: 1 addition & 0 deletions docs
Submodule docs added at e8ec16
28 changes: 14 additions & 14 deletions hololinked/client/factory.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import base64
import ssl
import threading
import uuid
import warnings

from typing import Any
Expand All @@ -22,11 +20,12 @@
EventAffordance,
PropertyAffordance,
)
from ..utils import set_global_event_loop_policy
from ..utils import set_global_event_loop_policy, uuid_hex
from .abstractions import ConsumedThingAction, ConsumedThingEvent, ConsumedThingProperty
from .http.consumed_interactions import HTTPAction, HTTPEvent, HTTPProperty
from .mqtt.consumed_interactions import MQTTConsumer # only one type for now
from .proxy import ObjectProxy
from .security import BasicSecurity
from .zmq.consumed_interactions import (
ReadMultipleProperties,
WriteMultipleProperties,
Expand Down Expand Up @@ -71,8 +70,6 @@ def zmq(

- `logger`: `logging.Logger`, optional.
A custom logger instance to use for logging
- `log_level`: `int`, default `logging.INFO`.
The logging level to use for the client (e.g., logging.DEBUG, logging.INFO)
- `ignore_TD_errors`: `bool`, default `False`.
Whether to ignore errors while fetching the Thing Description (TD)
- `skip_interaction_affordances`: `list[str]`, default `[]`.
Expand All @@ -87,7 +84,7 @@ def zmq(
ObjectProxy
An ObjectProxy instance representing the remote Thing
"""
id = f"{server_id}|{thing_id}|{access_point}|{uuid.uuid4()}"
id = kwargs.get("id", f"{server_id}|{thing_id}|{access_point}|{uuid_hex()}")

# configs
ignore_TD_errors = kwargs.get("ignore_TD_errors", False)
Expand Down Expand Up @@ -131,6 +128,7 @@ def zmq(
logger=logger,
invokation_timeout=invokation_timeout,
execution_timeout=execution_timeout,
security=kwargs.get("security", None),
)

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

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

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

TD = Serializers.json.loads(response.content)
id = f"client|{TD['id']}|HTTP|{uuid.uuid4().hex[:8]}"
id = kwargs.get("id", f"client|{TD['id']}|HTTP|{uuid_hex()}")
logger = kwargs.get("logger", structlog.get_logger()).bind(
component="client",
client_id=id,
protocol="http",
thing_id=TD["id"],
)
object_proxy = ObjectProxy(id, td=TD, logger=logger, **kwargs)
object_proxy = ObjectProxy(id, td=TD, logger=logger, security=security, **kwargs)

for name in TD.get("properties", []):
affordance = PropertyAffordance.from_TD(name, TD)
Expand Down Expand Up @@ -383,7 +383,7 @@ def mqtt(
- `log_level`: `int`, default `logging.INFO`.
The logging level to use for the client (e.g., logging.DEBUG, logging.INFO
"""
id = f"mqtt-client|{hostname}:{port}|{uuid.uuid4().hex[:8]}"
id = kwargs.get("id", f"mqtt-client|{hostname}:{port}|{uuid_hex()}")
logger = kwargs.get("logger", structlog.get_logger()).bind(
component="client",
client_id=id,
Expand Down
41 changes: 13 additions & 28 deletions hololinked/client/http/consumed_interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,32 +66,13 @@ def get_body_from_response(
return body
response.raise_for_status()

def _merge_auth_headers(self, base: dict | None = None):
headers = dict(base or {})

# Avoid truthiness on ObjectProxy
owner = getattr(self, "_owner_inst", None)
if owner is None:
owner = getattr(self, "owner", None)

auth = getattr(owner, "_auth_header", None) if owner is not None else None

# Normalize present header names (case-insensitive)
present = {k.lower() for k in headers}

if auth:
if isinstance(auth, dict):
# Merge key-by-key if caller stored a header dict
for k, v in auth.items():
if k.lower() not in present:
headers[k] = v
elif isinstance(auth, str):
# Caller stored just the value: "Basic abcd=="
if "authorization" not in present:
headers["Authorization"] = auth
else:
# Ignore unexpected types instead of crashing
pass
def _merge_auth_headers(self, base: dict[str, str]) -> dict[str, str]:
headers = base or {}

if not self.owner_inst or self.owner_inst._security is None:
return headers
if not any(key.lower() == "authorization" for key in headers.keys()):
headers["Authorization"] = self.owner_inst._security.http_header

return headers

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

try:
with self._sync_http_client.stream(
method="GET", url=form.href, headers=self._merge_auth_headers({"Accept": "text/event-stream"})
method="GET",
url=form.href,
headers=self._merge_auth_headers({"Accept": "text/event-stream"}),
) as resp:
resp.raise_for_status()
interrupting_event = threading.Event()
Expand Down Expand Up @@ -383,7 +366,9 @@ async def async_listen(

try:
async with self._async_http_client.stream(
method="GET", url=form.href, headers=self._merge_auth_headers({"Accept": "text/event-stream"})
method="GET",
url=form.href,
headers=self._merge_auth_headers({"Accept": "text/event-stream"}),
) as resp:
resp.raise_for_status()
interrupting_event = asyncio.Event()
Expand Down
12 changes: 2 additions & 10 deletions hololinked/client/proxy.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import base64

from typing import Any, Callable

import structlog
Expand Down Expand Up @@ -29,7 +27,7 @@ class ObjectProxy:
"_events",
"_noblock_messages",
"_schema_validator",
"_auth_header",
"_security",
]
)

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

self._auth_header = None
username = kwargs.get("username")
password = kwargs.get("password")
if username and password:
token = f"{username}:{password}".encode("utf-8")
self._auth_header = {"Authorization": f"Basic {base64.b64encode(token).decode('utf-8')}"}

def __getattribute__(self, __name: str) -> Any:
obj = super().__getattribute__(__name)
if isinstance(obj, ConsumedThingProperty):
Expand Down
19 changes: 19 additions & 0 deletions hololinked/client/security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import base64

from pydantic import BaseModel


class BasicSecurity(BaseModel):
"""Basic Security Scheme with username and password"""

credentials: str

def __init__(self, username: str, password: str, use_base64: bool = True):
credentials = f"{username}:{password}"
if use_base64:
credentials = base64.b64encode(credentials.encode("utf-8")).decode("utf-8")
self._credentials = f"Basic {credentials}"

@property
def http_header(self) -> str:
return self._credentials
2 changes: 1 addition & 1 deletion hololinked/core/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def validate_call(self, args, kwargs: dict[str, Any]) -> None:
else:
raise StateMachineError(
"Thing '{}' is in '{}' state, however action can be executed only in '{}' state".format(
f"{self.owner.__class__}.{self.owner_inst.id}",
self.owner_inst,
self.owner_inst.state,
self.execution_info.state,
)
Expand Down
6 changes: 4 additions & 2 deletions hololinked/core/state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,10 @@ def set_state(self, value: str | StrEnum | Enum, push_event: bool = True, skip_c
ValueError:
if the state is not found in the allowed states
"""

if value in self.states:
given_state = self.descriptor._get_machine_compliant_state(value)
if given_state == self.current_state:
return
previous_state = self.current_state
next_state = self.descriptor._get_machine_compliant_state(value)
self.owner._state_machine_state = next_state
Expand Down Expand Up @@ -366,6 +368,6 @@ def prepare_object_FSM(instance: Thing) -> None:
if cls.state_machine and isinstance(cls.state_machine, StateMachine):
cls.state_machine.validate(instance)
instance.logger.info(
f"setup state machine, states={[state.name for state in cls.state_machine.states]}, "
f"setup state machine, states={[state.name if hasattr(state, 'name') else state for state in cls.state_machine.states]}, "
+ f"initial_state={cls.state_machine.initial_state}"
)
4 changes: 2 additions & 2 deletions hololinked/server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import asyncio
import threading
import uuid
import warnings

from ..config import global_config
Expand All @@ -9,6 +8,7 @@
cancel_pending_tasks_in_current_loop,
forkable,
get_current_async_loop,
uuid_hex,
)
from .server import BaseProtocolServer

Expand All @@ -35,7 +35,7 @@ def run(*servers: BaseProtocolServer, forked: bool = False) -> None:
rpc_server = zmq_servers[0]
else:
rpc_server = RPCServer(
id=f"rpc-broker-{uuid.uuid4().hex[:8]}",
id=f"rpc-broker-{uuid_hex()}",
things=things,
context=global_config.zmq_context(),
)
Expand Down
11 changes: 10 additions & 1 deletion hololinked/server/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,8 @@ def add_interaction_affordances(
for action in actions:
if action in self:
continue
elif action.name == "get_thing_model":
continue
route = self.adapt_route(action.name)
if action.thing_id is not None:
path = f"/{action.thing_id}{route}"
Expand All @@ -554,8 +556,15 @@ def add_interaction_affordances(
path = f"/{event.thing_id}{route}"
self.server.add_event(URL_path=path, event=event, handler=self.server.event_handler)

# thing description handler
# thing model handler
get_thing_model_action = next((action for action in actions if action.name == "get_thing_model"), None)
self.server.add_action(
URL_path=f"/{thing_id}/resources/wot-tm" if thing_id else "/resources/wot-tm",
action=get_thing_model_action,
http_method=("GET",),
)

# thing description handler
get_thing_description_action = deepcopy(get_thing_model_action)
get_thing_description_action.override_defaults(name="get_thing_description")
self.server.add_action(
Expand Down
Loading
Loading