Skip to content

Commit 9e618fe

Browse files
authored
Merge pull request #160 from ChannelFinder/refactor-cf-subpackage
2 parents 2291b91 + 6ea5a5a commit 9e618fe

20 files changed

Lines changed: 1399 additions & 1570 deletions

.github/workflows/server.yml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,21 @@ jobs:
6868
- name: Test unit tests
6969
run: |
7070
set -o pipefail
71-
pytest tests/unit -v 2>&1 | tee pytest-unit.log
71+
pytest tests/unit -v --cov=recceiver --cov-report=xml:coverage.xml 2>&1 | tee pytest-unit.log
7272
- name: Upload test log
7373
if: always()
7474
uses: actions/upload-artifact@v4
7575
with:
7676
name: pytest-unit-log
7777
path: server/pytest-unit.log
7878
retention-days: 14
79+
- name: Upload coverage report
80+
if: always()
81+
uses: actions/upload-artifact@v4
82+
with:
83+
name: coverage-xml
84+
path: server/coverage.xml
85+
retention-days: 14
7986

8087
test-integration:
8188
runs-on: ubuntu-latest

server/pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ requires = [ "setuptools" ]
44

55
[project]
66
name = "recceiver"
7-
version = "1.9.3"
7+
version = "1.9.5"
88
description = """\
99
recCeiver is a server component of the recsync protocol. It receives record updates from recsync clients (e.g., \
1010
recCasters) and forwards them to a configurable backend such as ChannelFinder.\
@@ -36,11 +36,11 @@ dependencies = [
3636
"twisted>=22.10,<23; python_version<'3.8'",
3737
"twisted>=24.11,<24.12; python_version>='3.8'",
3838
]
39-
optional-dependencies.test = [ "pytest>=8.3,<8.4", "testcontainers>=4.8.2,<4.9" ]
39+
optional-dependencies.test = [ "pytest>=8.3,<8.4", "pytest-cov>=6,<7", "testcontainers>=4.8.2,<4.9" ]
4040
urls.Repository = "https://github.com/ChannelFinder/recsync"
4141

4242
[tool.setuptools]
43-
packages = [ "recceiver", "recceiver.protocol", "twisted.plugins" ]
43+
packages = [ "recceiver", "recceiver.cf", "recceiver.protocol", "twisted.plugins" ]
4444
include-package-data = true
4545
package-data.twisted = [ "plugins/recceiver_plugin.py" ]
4646

server/recceiver/cf/__init__.py

Whitespace-only changes.

server/recceiver/cf/adapter.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
from typing import List
2+
3+
try:
4+
from typing import Protocol
5+
except ImportError:
6+
from typing_extensions import Protocol # type: ignore[assignment]
7+
8+
from recceiver.cf.model import CFChannel, CFProperty, CFPropertyName, PVStatus
9+
10+
# CF query URLs break above this length; names are pipe-joined and chunked to stay under it.
11+
_CF_NAME_QUERY_LIMIT = 600
12+
13+
14+
class ChannelFinderAdapter(Protocol):
15+
"""Typed boundary between CFProcessor and the ChannelFinder HTTP client.
16+
17+
All methods accept and return domain objects (CFChannel, CFProperty).
18+
Dict serialisation is handled inside the implementation, not at callsites.
19+
"""
20+
21+
def find_by_ioc_id(self, iocid: str) -> List[CFChannel]:
22+
"""Return all channels registered under the given IOC ID."""
23+
...
24+
25+
def find_by_names(self, names: List[str]) -> List[CFChannel]:
26+
"""Return channels whose names are in the given list."""
27+
...
28+
29+
def find_active_for_recceiver(self, recceiverid: str) -> List[CFChannel]:
30+
"""Return all channels marked Active for the given recceiver."""
31+
...
32+
33+
def set_channels(self, channels: List[CFChannel]) -> None:
34+
"""Create or overwrite channels."""
35+
...
36+
37+
def update_property(self, prop: CFProperty, channel_names: List[str]) -> None:
38+
"""Update a single property value across the named channels."""
39+
...
40+
41+
def get_property_names(self) -> List[str]:
42+
"""Return the names of all property definitions registered in ChannelFinder."""
43+
...
44+
45+
def set_property(self, name: str, owner: str) -> None:
46+
"""Register a property definition if it does not already exist."""
47+
...
48+
49+
50+
class PyCFClientAdapter:
51+
"""Wraps pyCFClient's ChannelFinderClient to implement ChannelFinderAdapter."""
52+
53+
def __init__(self, client, size_limit: int = 0):
54+
self._client = client
55+
self._size_limit = size_limit
56+
57+
def _find(self, args: List) -> List[CFChannel]:
58+
if self._size_limit > 0:
59+
args = args + [("~size", self._size_limit)]
60+
return [CFChannel.from_dict(ch) for ch in self._client.findByArgs(args)]
61+
62+
def find_by_ioc_id(self, iocid: str) -> List[CFChannel]:
63+
return self._find([(CFPropertyName.IOC_ID.value, iocid)])
64+
65+
def find_by_names(self, names: List[str]) -> List[CFChannel]:
66+
if not names:
67+
return []
68+
chunks, buf = [], ""
69+
for name in names:
70+
if not buf:
71+
buf = name
72+
elif len(buf) + len(name) < _CF_NAME_QUERY_LIMIT:
73+
buf = buf + "|" + name
74+
else:
75+
chunks.append(buf)
76+
buf = name
77+
if buf:
78+
chunks.append(buf)
79+
results = []
80+
for chunk in chunks:
81+
results.extend(self._find([("~name", chunk)]))
82+
return results
83+
84+
def find_active_for_recceiver(self, recceiverid: str) -> List[CFChannel]:
85+
return self._find(
86+
[
87+
(CFPropertyName.PV_STATUS.value, PVStatus.ACTIVE.value),
88+
(CFPropertyName.RECCEIVER_ID.value, recceiverid),
89+
]
90+
)
91+
92+
def set_channels(self, channels: List[CFChannel]) -> None:
93+
self._client.set(channels=[ch.as_dict() for ch in channels])
94+
95+
def update_property(self, prop: CFProperty, channel_names: List[str]) -> None:
96+
self._client.update(property=prop.as_dict(), channelNames=channel_names)
97+
98+
def get_property_names(self) -> List[str]:
99+
return [p["name"] for p in self._client.getAllProperties()]
100+
101+
def set_property(self, name: str, owner: str) -> None:
102+
self._client.set(property={"name": name, "owner": owner})

server/recceiver/cf/config.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import socket
2+
from dataclasses import dataclass, fields
3+
from typing import Optional
4+
5+
from recceiver.processors import ConfigAdapter
6+
7+
RECCEIVERID_DEFAULT = socket.gethostname()
8+
DEFAULT_QUERY_LIMIT = 10_000
9+
10+
11+
@dataclass
12+
class CFConfig:
13+
"""Configuration options for the CF Processor."""
14+
15+
alias_enabled: bool = False
16+
record_type_enabled: bool = False
17+
environment_variables: str = ""
18+
info_tags: str = ""
19+
ioc_connection_info: bool = True
20+
record_description_enabled: bool = False
21+
clean_on_start: bool = True
22+
clean_on_stop: bool = True
23+
username: str = "cfstore"
24+
env_owner_variable: str = "ENGINEER"
25+
recceiver_id: str = RECCEIVERID_DEFAULT
26+
timezone: Optional[str] = None
27+
cf_query_limit: int = DEFAULT_QUERY_LIMIT
28+
base_url: Optional[str] = None
29+
cf_username: Optional[str] = None
30+
cf_password: Optional[str] = None
31+
verify_ssl: Optional[bool] = None
32+
push_max_retries: int = 10
33+
push_always_retry: bool = True
34+
35+
@classmethod
36+
def loads(cls, conf: ConfigAdapter) -> "CFConfig":
37+
"""Load configuration from a ConfigAdapter instance."""
38+
return CFConfig(
39+
alias_enabled=conf.getboolean("alias", False),
40+
record_type_enabled=conf.getboolean("recordType", False),
41+
environment_variables=conf.get("environment_vars", ""),
42+
info_tags=conf.get("infotags", ""),
43+
ioc_connection_info=conf.getboolean("iocConnectionInfo", True),
44+
record_description_enabled=conf.getboolean("recordDesc", False),
45+
clean_on_start=conf.getboolean("cleanOnStart", True),
46+
clean_on_stop=conf.getboolean("cleanOnStop", True),
47+
username=conf.get("username", "cfstore"),
48+
recceiver_id=conf.get("recceiverId", RECCEIVERID_DEFAULT),
49+
timezone=conf.get("timezone", ""),
50+
cf_query_limit=conf.get("findSizeLimit", DEFAULT_QUERY_LIMIT),
51+
base_url=conf.get("baseUrl"),
52+
cf_username=conf.get("cfUsername"),
53+
cf_password=conf.get("cfPassword"),
54+
verify_ssl=conf.getboolean("verifySSL"),
55+
push_max_retries=conf.getint("pushMaxRetries", 10),
56+
push_always_retry=conf.getboolean("pushAlwaysRetry", True),
57+
)
58+
59+
def __repr__(self) -> str:
60+
parts = []
61+
for f in fields(self):
62+
value = getattr(self, f.name)
63+
if f.name == "cf_password":
64+
value = "***" if value else None
65+
parts.append(f"{f.name}={value!r}")
66+
return f"CFConfig({', '.join(parts)})"

server/recceiver/cf/model.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import enum
2+
from dataclasses import dataclass, field
3+
from typing import Any, Dict, List, Optional
4+
5+
6+
class PVStatus(enum.Enum):
7+
"""Active/Inactive status values as used in the pvStatus CF property."""
8+
9+
ACTIVE = "Active"
10+
INACTIVE = "Inactive"
11+
12+
13+
class CFPropertyName(enum.Enum):
14+
"""Canonical property names registered and managed in Channelfinder."""
15+
16+
HOSTNAME = "hostName"
17+
IOC_NAME = "iocName"
18+
IOC_ID = "iocid"
19+
IOC_IP = "iocIP"
20+
PV_STATUS = "pvStatus"
21+
TIME = "time"
22+
RECCEIVER_ID = "recceiverID"
23+
ALIAS = "alias"
24+
RECORD_TYPE = "recordType"
25+
RECORD_DESC = "recordDesc"
26+
CA_PORT = "caPort"
27+
PVA_PORT = "pvaPort"
28+
29+
30+
@dataclass
31+
class CFProperty:
32+
"""A single named property attached to a Channelfinder channel."""
33+
34+
name: str
35+
owner: str
36+
value: Optional[str] = None
37+
38+
def as_dict(self) -> Dict[str, str]:
39+
"""Serialise to the dict shape expected by pyCFClient."""
40+
return {"name": self.name, "owner": self.owner, "value": self.value or ""}
41+
42+
@classmethod
43+
def from_dict(cls, prop_dict: Dict[str, str]) -> "CFProperty":
44+
"""Deserialise from the dict shape returned by pyCFClient."""
45+
return cls(
46+
name=prop_dict.get("name", ""),
47+
owner=prop_dict.get("owner", ""),
48+
value=prop_dict.get("value"),
49+
)
50+
51+
52+
@dataclass
53+
class CFChannel:
54+
"""A Channelfinder channel with its associated properties."""
55+
56+
name: str
57+
owner: str
58+
properties: List[CFProperty]
59+
60+
def as_dict(self) -> Dict[str, Any]:
61+
"""Serialise to the dict shape expected by pyCFClient."""
62+
return {
63+
"name": self.name,
64+
"owner": self.owner,
65+
"properties": [p.as_dict() for p in self.properties],
66+
}
67+
68+
@classmethod
69+
def from_dict(cls, channel_dict: Dict[str, Any]) -> "CFChannel":
70+
"""Deserialise from the dict shape returned by pyCFClient."""
71+
return cls(
72+
name=channel_dict.get("name", ""),
73+
owner=channel_dict.get("owner", ""),
74+
properties=[CFProperty.from_dict(p) for p in channel_dict.get("properties", [])],
75+
)
76+
77+
78+
@dataclass
79+
class IOCInfo:
80+
"""Runtime state for a connected IOC. The .id property is the primary key."""
81+
82+
host: str
83+
hostname: str
84+
ioc_name: str
85+
ioc_ip: str
86+
owner: str
87+
time: str
88+
port: int
89+
channelcount: int = 0
90+
91+
@property
92+
def id(self) -> str:
93+
return f"{self.host}:{self.port}"
94+
95+
96+
@dataclass
97+
class RecordInfo:
98+
"""Per-record data extracted from a transaction before pushing to CF."""
99+
100+
pv_name: str
101+
record_type: Optional[str] = None
102+
info_properties: List[CFProperty] = field(default_factory=list)
103+
aliases: List[str] = field(default_factory=list)
104+
105+
106+
class IOCMissingInfoError(Exception):
107+
"""Raised when an IOC is missing required information."""
108+
109+
def __init__(self, ioc_info: IOCInfo):
110+
super().__init__(f"Missing hostName {ioc_info.hostname} or iocName {ioc_info.ioc_name}")
111+
self.ioc_info = ioc_info

0 commit comments

Comments
 (0)