scpipy 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
scpipy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,4 @@
1
+ Metadata-Version: 2.4
2
+ Name: scpipy
3
+ Version: 0.1.0
4
+ Summary: Python SCPI I/O library with built-in client and server.
scpipy-0.1.0/README.md ADDED
@@ -0,0 +1,31 @@
1
+ SCPIpy
2
+ ======
3
+
4
+ A python package for support of the Standard Commands for Programmable Instruments (SCPI) that allows you to create a server and an asynchronous client.
5
+
6
+ Descirption
7
+ -----------
8
+
9
+ The package provides (almost) full support for the SCPI standard for server creation, which simplifies the process of writing your code.
10
+
11
+ “Full support” refers to all standard functionality (except for status registry at this time), including a command parser that constructs an AST tree.
12
+
13
+ In addition, this package allows you to implement asynchronous communication with another SCPI server, since we have an asynchronous wrapper for pyVISA.
14
+
15
+ Requirements
16
+ ------------
17
+
18
+ - Python (3.10+)
19
+ - VISA (or pyvisa-py for client)
20
+
21
+ Installation
22
+ ------------
23
+
24
+ Using pip:
25
+
26
+ $ pip install scpipy
27
+
28
+ Documentation
29
+ -------------
30
+
31
+ The documentation can be read online at https://scpipy.readthedocs.org
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "scpipy"
7
+ version = "0.1.0"
8
+ description = "Python SCPI I/O library with built-in client and server."
9
+
10
+ [tool.setuptools.package-dir]
11
+ "" = "src"
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["src"]
scpipy-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
scpipy-0.1.0/setup.py ADDED
@@ -0,0 +1,3 @@
1
+ import setuptools
2
+
3
+ setuptools.setup()
File without changes
@@ -0,0 +1 @@
1
+ from scpipy.client.client import Client
@@ -0,0 +1,136 @@
1
+ import asyncio
2
+ import contextlib
3
+ import pyvisa
4
+
5
+
6
+ from scpipy.client.exceptions import ClientConnectionError
7
+ from scpipy.client.worker import Worker
8
+
9
+
10
+ class Client:
11
+ def __init__(
12
+ self,
13
+ resource: str,
14
+ *,
15
+ backend: str | None = None,
16
+ open_timeout: int = 5000,
17
+ io_timeout: int = 5000,
18
+ read_termination: str = '\n',
19
+ write_termination: str = '\n',
20
+ encoding: str = 'utf-8',
21
+ chunk_size: int = 1024 * 1024,
22
+ queue_size: int = 0,
23
+ ):
24
+ self._resource = resource
25
+ self._backend = backend
26
+ self._open_timeout = open_timeout
27
+ self._io_timeout = io_timeout
28
+ self._read_termination = read_termination
29
+ self._write_termination = write_termination
30
+ self._encoding = encoding
31
+ self._chunk_size = chunk_size
32
+ self._queue_size = queue_size
33
+
34
+ self._rm: pyvisa.ResourceManager | None = None
35
+ self._instrument: pyvisa.resources.MessageBasedResource | None = None
36
+ self._worker: Worker | None = None
37
+
38
+ self._started = False
39
+ self._closed = False
40
+
41
+ async def connect(self):
42
+ if self._started:
43
+ return
44
+ if self._closed:
45
+ raise ClientConnectionError('Client is already closed')
46
+
47
+ try:
48
+ if self._backend:
49
+ self._rm = await asyncio.to_thread(
50
+ pyvisa.ResourceManager, self._backend
51
+ )
52
+ else:
53
+ self._rm = await asyncio.to_thread(pyvisa.ResourceManager)
54
+
55
+ self._instrument = await asyncio.to_thread(
56
+ self._rm.open_resource,
57
+ self._resource,
58
+ open_timeout=self._open_timeout,
59
+ resource_pyclass=pyvisa.resources.MessageBasedResource,
60
+ )
61
+
62
+ self._instrument.timeout = self._io_timeout
63
+ self._instrument.read_termination = self._read_termination
64
+ self._instrument.write_termination = self._write_termination
65
+ self._instrument.chunk_size = self._chunk_size
66
+ self._instrument.encoding = self._encoding
67
+
68
+ self._worker = Worker(
69
+ self._instrument, queue_size=self._queue_size
70
+ )
71
+ self._worker.start()
72
+ self._started = True
73
+
74
+ except pyvisa.errors.VisaIOError as exc:
75
+ await self._cleanup_connect_error()
76
+ raise ClientConnectionError(
77
+ f'Failed to connect to {self._resource}: {exc}'
78
+ ) from exc
79
+ except Exception:
80
+ await self._cleanup_connect_error()
81
+ raise
82
+
83
+ async def close(self):
84
+ if self._closed:
85
+ return
86
+
87
+ try:
88
+ if self._worker is not None:
89
+ await self._worker.close()
90
+ finally:
91
+ self._worker = None
92
+ self._instrument = None
93
+
94
+ if self._rm is not None:
95
+ await asyncio.to_thread(self._rm.close)
96
+ self._rm = None
97
+
98
+ self._started = False
99
+ self._closed = True
100
+
101
+ async def __aenter__(self) -> 'Client':
102
+ await self.connect()
103
+ return self
104
+
105
+ async def __aexit__(self, exc_type, exc, tb):
106
+ await self.close()
107
+
108
+ async def write(self, command: str) -> int:
109
+ worker = self._get_worker()
110
+ return await worker.write(command)
111
+
112
+ async def read(self) -> str:
113
+ worker = self._get_worker()
114
+ return await worker.read()
115
+
116
+ async def query(self, command: str) -> str:
117
+ worker = self._get_worker()
118
+ return await worker.query(command)
119
+
120
+ def _get_worker(self) -> Worker:
121
+ if not self._started or self._worker is None:
122
+ raise ClientConnectionError('Client is not connected')
123
+ if self._closed:
124
+ raise ClientConnectionError('Client is already closed')
125
+ return self._worker
126
+
127
+ async def _cleanup_connect_error(self):
128
+ self._worker = None
129
+ self._instrument = None
130
+
131
+ if self._rm is not None:
132
+ with contextlib.suppress(Exception):
133
+ await asyncio.to_thread(self._rm.close)
134
+ self._rm = None
135
+
136
+ self._started = False
@@ -0,0 +1,10 @@
1
+ class ClientTimeoutError(Exception):
2
+ pass
3
+
4
+
5
+ class ClientConnectionError(Exception):
6
+ pass
7
+
8
+
9
+ class ClientStopped(Exception):
10
+ pass
@@ -0,0 +1,17 @@
1
+ import asyncio
2
+ import dataclasses
3
+ import enum
4
+
5
+
6
+ class OperationType(enum.Enum):
7
+ WRITE = enum.auto()
8
+ READ = enum.auto()
9
+ QUERY = enum.auto()
10
+ CLOSE = enum.auto()
11
+
12
+
13
+ @dataclasses.dataclass(slots=True)
14
+ class QueueItem:
15
+ type: OperationType
16
+ value: str | None
17
+ future: asyncio.Future
@@ -0,0 +1,150 @@
1
+ import asyncio
2
+ import contextlib
3
+ import pyvisa
4
+
5
+ from scpipy.client.exceptions import ClientConnectionError, ClientStopped
6
+ from scpipy.client.models import QueueItem, OperationType
7
+
8
+
9
+ class Worker:
10
+
11
+ def __init__(
12
+ self,
13
+ instrument: pyvisa.resources.MessageBasedResource,
14
+ *,
15
+ queue_size: int = 0,
16
+ ):
17
+ self._instrument = instrument
18
+ self._queue: asyncio.Queue[QueueItem] = asyncio.Queue(
19
+ maxsize=queue_size
20
+ )
21
+ self._task: asyncio.Task[None] | None = None
22
+ self._closing = False
23
+ self._closed = False
24
+
25
+ def start(self):
26
+ if self._task is not None:
27
+ return
28
+ self._task = asyncio.create_task(
29
+ self._run(), name='scpipy-client-worker'
30
+ )
31
+
32
+ async def write(self, command: str) -> int:
33
+ result = await self._submit(OperationType.WRITE, command)
34
+ return int(result)
35
+
36
+ async def read(self) -> str:
37
+ result = await self._submit(OperationType.READ, None)
38
+ return str(result)
39
+
40
+ async def query(self, command: str) -> str:
41
+ result = await self._submit(OperationType.QUERY, command)
42
+ return str(result)
43
+
44
+ async def close(self):
45
+ if self._closed or self._closing:
46
+ return
47
+
48
+ self._closing = True
49
+ loop = asyncio.get_running_loop()
50
+ future: asyncio.Future[int | str | None] = loop.create_future()
51
+
52
+ await self._queue.put(QueueItem(OperationType.CLOSE, None, future))
53
+
54
+ try:
55
+ await future
56
+ finally:
57
+ if self._task is not None:
58
+ with contextlib.suppress(asyncio.CancelledError):
59
+ await self._task
60
+
61
+ self._closed = True
62
+ self._closing = False
63
+
64
+ async def _submit(
65
+ self,
66
+ op: OperationType,
67
+ value: str | None,
68
+ ) -> int | str | None:
69
+ if self._closed:
70
+ raise ClientConnectionError('Client is already closed')
71
+ if self._closing:
72
+ raise ClientConnectionError('Client is closing')
73
+ if self._task is None:
74
+ raise ClientConnectionError('Client worker is not started')
75
+
76
+ loop = asyncio.get_running_loop()
77
+ future: asyncio.Future[int | str | None] = loop.create_future()
78
+ await self._queue.put(QueueItem(op, value, future))
79
+ return await future
80
+
81
+ async def _run(self):
82
+ try:
83
+ while True:
84
+ item = await self._queue.get()
85
+ try:
86
+ if item.type is OperationType.CLOSE:
87
+ await asyncio.to_thread(self._sync_close)
88
+ if not item.future.done():
89
+ item.future.set_result(None)
90
+ break
91
+
92
+ result = await self._execute_item(item)
93
+
94
+ if not item.future.done():
95
+ item.future.set_result(result)
96
+
97
+ except asyncio.CancelledError:
98
+ raise
99
+ except pyvisa.errors.VisaIOError as exc:
100
+ if not item.future.done():
101
+ item.future.set_exception(exc)
102
+ except Exception as exc:
103
+ if not item.future.done():
104
+ item.future.set_exception(exc)
105
+ finally:
106
+ self._queue.task_done()
107
+
108
+ except asyncio.CancelledError:
109
+ raise
110
+ finally:
111
+ self._fail_pending_operations(ClientStopped())
112
+ self._closed = True
113
+
114
+ async def _execute_item(self, item: QueueItem) -> int | str:
115
+ match (item.type):
116
+ case OperationType.WRITE:
117
+ if item.value is None:
118
+ raise ValueError('Write operation requires command')
119
+ return await asyncio.to_thread(
120
+ self._instrument.write, item.value
121
+ )
122
+
123
+ case OperationType.READ:
124
+ return await asyncio.to_thread(self._instrument.read)
125
+
126
+ case OperationType.QUERY:
127
+ if item.value is None:
128
+ raise ValueError('Query operation requires command')
129
+ return await asyncio.to_thread(
130
+ self._instrument.query, item.value
131
+ )
132
+
133
+ case _:
134
+ raise ValueError(f'Unsupported operation type: {item.type}')
135
+
136
+ def _sync_close(self):
137
+ self._instrument.close()
138
+
139
+ def _fail_pending_operations(self, exc: Exception):
140
+ while True:
141
+ try:
142
+ item = self._queue.get_nowait()
143
+ except asyncio.QueueEmpty:
144
+ break
145
+
146
+ try:
147
+ if not item.future.done():
148
+ item.future.set_exception(exc)
149
+ finally:
150
+ self._queue.task_done()
@@ -0,0 +1,3 @@
1
+ from scpipy.server.server import Server
2
+ from scpipy.server.routing import Router
3
+ from scpipy.server.context import Context
@@ -0,0 +1,14 @@
1
+ from scpipy.server.context import Context
2
+ from scpipy.server.routing import Router
3
+
4
+ builtin_router = Router()
5
+
6
+
7
+ @builtin_router.register('*CLS')
8
+ def clear_errors(context: Context):
9
+ context.clear_errors()
10
+
11
+
12
+ @builtin_router.register('SYSTem:ERRor[:NEXT]?')
13
+ def system_error(context: Context) -> str:
14
+ return context.get_error()
@@ -0,0 +1,18 @@
1
+ from scpipy.shared.errors import (
2
+ ErrorQueue,
3
+ ScpiError,
4
+ )
5
+
6
+
7
+ class Context:
8
+ def __init__(self, error_queue_size: int = 100):
9
+ self.error_queue = ErrorQueue(error_queue_size)
10
+
11
+ def push_error(self, error: ScpiError):
12
+ self.error_queue.put(error)
13
+
14
+ def get_error(self) -> str:
15
+ return str(self.error_queue.get())
16
+
17
+ def clear_errors(self):
18
+ self.error_queue.clear()
@@ -0,0 +1,103 @@
1
+ import asyncio
2
+
3
+ from scpipy.server.exceptions import RouteNotFound
4
+ from scpipy.server.routing import Router, Route
5
+ from scpipy.server.context import Context
6
+
7
+ from scpipy.shared.parser import Parser, ParseError
8
+ from scpipy.shared.ast import Command, Node
9
+ from scpipy.shared.errors import ScpiException, DefaultScpiErrors
10
+
11
+
12
+ class Dispatcher:
13
+ def __init__(self, router: Router, terminator: str):
14
+ self._router = router
15
+ self._terminator = terminator
16
+
17
+ self._parser = Parser()
18
+
19
+ async def _dispatch(self, context: Context, data: bytes) -> bytes | None:
20
+ line = data.decode()
21
+
22
+ try:
23
+ commands = self._parser.parse(line)
24
+ except ParseError:
25
+ raise ScpiException(DefaultScpiErrors.SYNTAX_ERROR.value)
26
+
27
+ responses = []
28
+
29
+ for command in commands:
30
+ response = await self._dispatch_command(context, command)
31
+ if response is not None:
32
+ responses.append(response)
33
+
34
+ if not responses:
35
+ return None
36
+
37
+ payload = ';'.join(responses) + self._terminator
38
+ return payload.encode()
39
+
40
+ async def _dispatch_command(
41
+ self, context: Context, command: Command
42
+ ) -> str | None:
43
+ try:
44
+ route = self._route(command)
45
+ except RouteNotFound:
46
+ raise ScpiException(DefaultScpiErrors.COMMAND_HEADER_ERROR.value)
47
+
48
+ args = [arg.value for arg in command.args]
49
+
50
+ try:
51
+ result = route.handler(context, *args)
52
+ except TypeError:
53
+ raise ScpiException(DefaultScpiErrors.DATA_TYPE_ERROR.value)
54
+
55
+ if asyncio.iscoroutine(result):
56
+ result = await result
57
+
58
+ if result is None:
59
+ return None
60
+
61
+ return str(result)
62
+
63
+ def _route(self, command: Command) -> Route:
64
+ for route in self._router.routes.values():
65
+ if self._match_command(command, route.pattern):
66
+ return route
67
+
68
+ raise RouteNotFound
69
+
70
+ def _match_command(self, command: Command, pattern: Command) -> bool:
71
+ if command.common != pattern.common:
72
+ return False
73
+
74
+ if command.query != pattern.query:
75
+ return False
76
+
77
+ return self._match_nodes(command.nodes, pattern.nodes)
78
+
79
+ def _match_nodes(
80
+ self, nodes: list[Node], pattern_nodes: list[Node]
81
+ ) -> bool:
82
+ node_index = 0
83
+
84
+ for pattern_node in pattern_nodes:
85
+ if node_index >= len(nodes):
86
+ return pattern_node.optional
87
+
88
+ if self._match_node(nodes[node_index], pattern_node):
89
+ node_index += 1
90
+ elif not pattern_node.optional:
91
+ return False
92
+
93
+ return node_index == len(nodes)
94
+
95
+ @staticmethod
96
+ def _match_node(node: Node, pattern: Node) -> bool:
97
+ if node.short != pattern.short:
98
+ return False
99
+
100
+ node_arg = node.arg.value if node.arg is not None else None
101
+ pattern_arg = pattern.arg.value if pattern.arg is not None else None
102
+
103
+ return node_arg == pattern_arg
@@ -0,0 +1,14 @@
1
+ class ConfigureException(Exception):
2
+ pass
3
+
4
+
5
+ class AlreadyStarted(Exception):
6
+ pass
7
+
8
+
9
+ class LogicException(Exception):
10
+ pass
11
+
12
+
13
+ class RouteNotFound(Exception):
14
+ pass
@@ -0,0 +1,68 @@
1
+ import dataclasses
2
+ from collections.abc import Callable, Iterable
3
+
4
+ from scpipy.server.exceptions import RouteNotFound
5
+
6
+ from scpipy.shared.parser import Parser
7
+ from scpipy.shared.ast import Command
8
+
9
+
10
+ @dataclasses.dataclass
11
+ class Route:
12
+ command: str
13
+ handler: Callable
14
+
15
+ pattern: Command = dataclasses.field(init=False)
16
+
17
+ def __post_init__(self):
18
+ parser = Parser()
19
+ commands = parser.parse(self.command)
20
+
21
+ if len(commands) != 1:
22
+ raise ValueError('Route must contain exactly one command')
23
+
24
+ self.pattern = commands[0]
25
+
26
+
27
+ class Router:
28
+ def __init__(self):
29
+ self._routes: dict[str, Route] = {}
30
+
31
+ @property
32
+ def routes(self) -> dict[str, Route]:
33
+ return self._routes
34
+
35
+ def route(self, command: str) -> Route:
36
+ target = self.routes.get(command)
37
+ if target is None:
38
+ raise RouteNotFound
39
+
40
+ return target
41
+
42
+ def register(self, command: str):
43
+ def wrapper(func: Callable):
44
+ self._add_route(command, func)
45
+
46
+ return wrapper
47
+
48
+ def include_router(self, routers: 'Router' | Iterable['Router']):
49
+ if isinstance(routers, Router):
50
+ routers = [routers]
51
+
52
+ for router in routers:
53
+ for route in router.routes.values():
54
+ if route.command in self.routes.keys():
55
+ raise ValueError('Route already registered')
56
+
57
+ self._add_route(route.command, route.handler)
58
+
59
+ def _add_route(self, command: str, handler: Callable):
60
+ route = Route(command=command, handler=handler)
61
+
62
+ if any(
63
+ existing.pattern == route.pattern
64
+ for existing in self._routes.values()
65
+ ):
66
+ raise ValueError('Route already registered')
67
+
68
+ self._routes[command] = route
@@ -0,0 +1,163 @@
1
+ import asyncio
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from . import exceptions
6
+ from . import dispatcher
7
+ from scpipy.server.routing import Router
8
+ from scpipy.server.context import Context
9
+ from scpipy.server.builtin_router import builtin_router
10
+
11
+ from scpipy.shared.errors import ScpiException
12
+
13
+
14
+ class Server:
15
+ def __init__(
16
+ self,
17
+ host: str,
18
+ port: int,
19
+ terminator: str = '\n',
20
+ error_queue_size: int = 100,
21
+ shared_context: bool = False,
22
+ ):
23
+ self._host: str = host
24
+ self._port: int = port
25
+
26
+ self._server: asyncio.Server | None = None
27
+ self._stop_event: asyncio.Event = asyncio.Event()
28
+
29
+ self._client_tasks: set[asyncio.Task] = set()
30
+ self._client_writers: set[asyncio.StreamWriter] = set()
31
+
32
+ self._router = builtin_router
33
+ self._dispatcher = dispatcher.Dispatcher(self._router, terminator)
34
+
35
+ self._error_queue_size = error_queue_size
36
+
37
+ self._context = None
38
+ if shared_context:
39
+ self._context = self._create_context()
40
+
41
+ def include_router(self, routers: Router | Iterable[Router]):
42
+ self._router.include_router(routers)
43
+
44
+ def run(self):
45
+ if self._host is None or self._port is None:
46
+ raise exceptions.ConfigureException(
47
+ 'Host or port was not configured'
48
+ )
49
+
50
+ if self._server is not None:
51
+ raise exceptions.AlreadyStarted('Server already started')
52
+
53
+ self._stop_event.clear()
54
+ asyncio.run(self._listen())
55
+
56
+ def stop(self):
57
+ self._stop_event.set()
58
+
59
+ async def _listen(self):
60
+ self._server = await asyncio.start_server(
61
+ self._handle_client, self._host, self._port
62
+ )
63
+
64
+ await self._wait_stop()
65
+ await self._close()
66
+ await self._close_writers()
67
+ await self._close_tasks()
68
+
69
+ async def _wait_stop(self):
70
+ await self._stop_event.wait()
71
+
72
+ async def _handle_client(
73
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
74
+ ):
75
+ if self._dispatcher is None:
76
+ raise exceptions.LogicException('Dispatcher was not configured')
77
+
78
+ context = self._get_context()
79
+
80
+ task = asyncio.current_task()
81
+ self._init_client(writer, task)
82
+
83
+ try:
84
+ while not self._stop_event.is_set():
85
+ data = await reader.readline()
86
+ if not data:
87
+ break
88
+
89
+ try:
90
+ response = await self._dispatcher._dispatch(context, data)
91
+ except ScpiException as exc:
92
+ context.push_error(exc.error)
93
+ continue
94
+
95
+ if response:
96
+ writer.write(response)
97
+ await writer.drain()
98
+
99
+ except asyncio.CancelledError:
100
+ raise
101
+ finally:
102
+ await self._close_client(writer, task)
103
+
104
+ def _init_client(
105
+ self, writer: asyncio.StreamWriter, task: asyncio.Task | None
106
+ ):
107
+ if task is not None:
108
+ self._client_tasks.add(task)
109
+ self._client_writers.add(writer)
110
+
111
+ async def _close_client(
112
+ self, writer: asyncio.StreamWriter, task: asyncio.Task | None
113
+ ):
114
+ self._client_writers.discard(writer)
115
+
116
+ if task is not None:
117
+ self._client_tasks.discard(task)
118
+
119
+ writer.close()
120
+ try:
121
+ await writer.wait_closed()
122
+ except Exception:
123
+ pass
124
+
125
+ async def _close(self):
126
+ if self._server is None:
127
+ raise exceptions.LogicException('Server cleaned up before')
128
+
129
+ self._server.close()
130
+ await self._server.wait_closed()
131
+ self._server = None
132
+
133
+ async def _close_writers(self):
134
+ writers = list(self._client_writers)
135
+ for writer in writers:
136
+ writer.close()
137
+
138
+ if writers:
139
+ await asyncio.gather(
140
+ *(writer.wait_closed() for writer in writers),
141
+ return_exceptions=True
142
+ )
143
+
144
+ async def _close_tasks(self):
145
+ tasks = list(self._client_tasks)
146
+ for task in tasks:
147
+ task.cancel()
148
+
149
+ if tasks:
150
+ await asyncio.gather(*tasks, return_exceptions=True)
151
+
152
+ def _get_context(self) -> Context:
153
+ if self._shared_context():
154
+ assert self._context is not None
155
+ return self._context
156
+
157
+ return self._create_context()
158
+
159
+ def _create_context(self) -> Context:
160
+ return Context(error_queue_size=self._error_queue_size)
161
+
162
+ def _shared_context(self) -> bool:
163
+ return self._context is not None
@@ -0,0 +1 @@
1
+ from scpipy.shared.parser import Parser
@@ -0,0 +1,23 @@
1
+ import dataclasses
2
+
3
+
4
+ @dataclasses.dataclass(frozen=True, slots=True)
5
+ class Argument:
6
+ value: str
7
+
8
+
9
+ @dataclasses.dataclass(frozen=True, slots=True)
10
+ class Node:
11
+ short: str
12
+ full: str
13
+ arg: Argument | None = None
14
+ optional: bool = False
15
+
16
+
17
+ @dataclasses.dataclass(frozen=True, slots=True)
18
+ class Command:
19
+ nodes: list[Node]
20
+ args: list[Argument] = dataclasses.field(default_factory=list)
21
+ query: bool = False
22
+ root_node: bool = False
23
+ common: bool = False
@@ -0,0 +1,48 @@
1
+ import collections
2
+ import dataclasses
3
+ import enum
4
+
5
+
6
+ @dataclasses.dataclass(frozen=True, slots=True)
7
+ class ScpiError:
8
+ code: int
9
+ message: str
10
+
11
+ def __str__(self) -> str:
12
+ return f'{self.code},"{self.message}"'
13
+
14
+
15
+ class DefaultScpiErrors(enum.Enum):
16
+ NO_ERROR = ScpiError(0, 'No error')
17
+ SYNTAX_ERROR = ScpiError(-102, 'Syntax error')
18
+ DATA_TYPE_ERROR = ScpiError(-104, 'Data type error')
19
+ COMMAND_HEADER_ERROR = ScpiError(-110, 'Command header error')
20
+ QUEUE_OVERFLOW = ScpiError(-350, 'Queue overflow')
21
+
22
+
23
+ class ScpiException(Exception):
24
+ def __init__(self, error: ScpiError):
25
+ self.error = error
26
+ super().__init__(str(error))
27
+
28
+
29
+ class ErrorQueue:
30
+ def __init__(self, max_size: int):
31
+ self.__queue = collections.deque(maxlen=max_size)
32
+
33
+ def get(self) -> str:
34
+ if not self.__queue:
35
+ return str(DefaultScpiErrors.NO_ERROR.value)
36
+
37
+ return str(self.__queue.popleft())
38
+
39
+ def put(self, item: ScpiError):
40
+ if len(self.__queue) == self.__queue.maxlen:
41
+ self.__queue.pop()
42
+ self.__queue.append(DefaultScpiErrors.QUEUE_OVERFLOW.value)
43
+ return
44
+
45
+ self.__queue.append(item)
46
+
47
+ def clear(self):
48
+ self.__queue.clear()
@@ -0,0 +1,2 @@
1
+ class ParseError(Exception):
2
+ pass
@@ -0,0 +1,205 @@
1
+ import re
2
+
3
+ from scpipy.shared.ast import Argument, Command, Node
4
+ from scpipy.shared.exceptions import ParseError
5
+ from scpipy.shared.utils import (
6
+ format_scpi_full_keyword,
7
+ format_scpi_short_keyword,
8
+ )
9
+
10
+ _NODE_TOKEN_RE = re.compile(r'^([A-Z*]+)(\d+)?$')
11
+
12
+
13
+ class Parser:
14
+ def parse(self, line: str) -> list[Command]:
15
+ result = []
16
+ scope_nodes = []
17
+
18
+ for command in self._split_block(line):
19
+ parsed = self._parse_command(command, scope_nodes)
20
+
21
+ if parsed.common:
22
+ scope_nodes = []
23
+ else:
24
+ scope_nodes = parsed.nodes[:-1]
25
+
26
+ result.append(parsed)
27
+
28
+ return result
29
+
30
+ def _parse_command(self, command: str, scope_nodes: list[Node]) -> Command:
31
+ if not (command := command.strip()):
32
+ raise ParseError('Empty command')
33
+
34
+ common = command.startswith('*')
35
+ rooted = common or command.startswith(':')
36
+
37
+ if rooted and not common:
38
+ command = command[1:]
39
+
40
+ header, args = self._split_node_and_args(command)
41
+ args = self._build_args(args)
42
+
43
+ query = header.endswith('?')
44
+ if query:
45
+ header = header[:-1]
46
+
47
+ if common:
48
+ nodes = [self._build_node(header, optional=False)]
49
+ else:
50
+ local_nodes = self._parse_header_nodes(header)
51
+
52
+ if rooted or not scope_nodes:
53
+ nodes = local_nodes
54
+ else:
55
+ nodes = scope_nodes + local_nodes
56
+
57
+ return Command(
58
+ nodes=nodes,
59
+ args=args,
60
+ query=query,
61
+ root_node=rooted,
62
+ common=common,
63
+ )
64
+
65
+ @staticmethod
66
+ def _split_block(line: str) -> list[str]:
67
+ line = line.strip()
68
+ if not line:
69
+ raise ParseError('Empty line')
70
+
71
+ return [
72
+ command.strip() for command in line.split(';') if command.strip()
73
+ ]
74
+
75
+ @classmethod
76
+ def _parse_header_nodes(cls, header: str) -> list[Node]:
77
+ parts = cls._split_nodes(header)
78
+ nodes = []
79
+ for node in parts:
80
+ nodes.extend(cls._parse_node_part(node))
81
+ return nodes
82
+
83
+ @staticmethod
84
+ def _split_nodes(command: str) -> list[str]:
85
+ command = command.strip()
86
+ if not command:
87
+ raise ParseError('Command must contain at least one node')
88
+
89
+ nodes = []
90
+ current = ''
91
+ in_brackets = False
92
+
93
+ for character in command:
94
+ if character == '[':
95
+ if in_brackets:
96
+ raise ParseError('Invalid bracket usage')
97
+ in_brackets = True
98
+ elif character == ']':
99
+ if not in_brackets:
100
+ raise ParseError('Invalid bracket usage')
101
+ in_brackets = False
102
+ elif character == ':' and not in_brackets:
103
+ node = current.strip()
104
+ if node:
105
+ nodes.append(node)
106
+ current = ''
107
+ continue
108
+
109
+ current += character
110
+
111
+ if in_brackets:
112
+ raise ParseError('Invalid bracket usage')
113
+
114
+ last_node = current.strip()
115
+ if last_node:
116
+ nodes.append(last_node)
117
+
118
+ if not nodes:
119
+ raise ParseError('Command must contain at least one node')
120
+
121
+ return nodes
122
+
123
+ @classmethod
124
+ def _parse_node_part(cls, line: str) -> list[Node]:
125
+ line = line.strip()
126
+ if not line:
127
+ raise ParseError('Invalid node')
128
+
129
+ if '[:' not in line:
130
+ if '[' in line or ']' in line:
131
+ raise ParseError('Invalid bracket usage')
132
+ return [cls._build_node(line, optional=False)]
133
+
134
+ required_node, optional_node = cls._split_optional_node(line)
135
+
136
+ nodes: list[Node] = []
137
+ if required_node:
138
+ nodes.append(cls._build_node(required_node, optional=False))
139
+ nodes.append(cls._build_node(optional_node, optional=True))
140
+ return nodes
141
+
142
+ @staticmethod
143
+ def _split_optional_node(line: str) -> tuple[str, str]:
144
+ if not line.endswith(']'):
145
+ raise ParseError('Invalid bracket usage')
146
+
147
+ body = line[:-1]
148
+ required_node, optional_node = body.split('[:', 1)
149
+
150
+ required_node = required_node.strip()
151
+ optional_node = optional_node.strip()
152
+
153
+ if not optional_node:
154
+ raise ParseError('Invalid node')
155
+
156
+ return required_node, optional_node
157
+
158
+ @classmethod
159
+ def _build_node(cls, token: str, optional: bool) -> Node:
160
+ keyword, arg = cls._split_node(token)
161
+ arg = cls._build_arg(arg) if arg is not None else None
162
+
163
+ return Node(
164
+ short=format_scpi_short_keyword(keyword),
165
+ full=format_scpi_full_keyword(keyword),
166
+ arg=arg,
167
+ optional=optional,
168
+ )
169
+
170
+ @staticmethod
171
+ def _build_arg(value: str) -> Argument:
172
+ return Argument(value=value)
173
+
174
+ @classmethod
175
+ def _build_args(cls, args: list[str]) -> list[Argument]:
176
+ return [cls._build_arg(arg) for arg in args]
177
+
178
+ @staticmethod
179
+ def _split_node(node: str) -> tuple[str, str | None]:
180
+ node = node.strip()
181
+ if not node:
182
+ raise ParseError('Invalid node')
183
+
184
+ match = _NODE_TOKEN_RE.fullmatch(node.upper())
185
+ if not match:
186
+ raise ParseError('Invalid node')
187
+
188
+ keyword = match.group(1)
189
+ arg = match.group(2)
190
+ return keyword, arg
191
+
192
+ @staticmethod
193
+ def _split_node_and_args(line: str) -> tuple[str, list[str]]:
194
+ line = line.strip()
195
+ if not line:
196
+ raise ParseError('Empty command')
197
+
198
+ parts = line.split(maxsplit=1)
199
+ node = parts[0]
200
+
201
+ if len(parts) == 1:
202
+ return node, []
203
+
204
+ args = [arg.strip() for arg in parts[1].split(',') if arg.strip()]
205
+ return node, args
@@ -0,0 +1,14 @@
1
+ def scpi_short_length(keyword: str) -> int:
2
+ if len(keyword) < 4:
3
+ return len(keyword)
4
+ return 3 if keyword[3] in 'aeiouAEIOU' else 4
5
+
6
+
7
+ def format_scpi_short_keyword(keyword: str) -> str:
8
+ n = scpi_short_length(keyword)
9
+ return keyword[:n].upper()
10
+
11
+
12
+ def format_scpi_full_keyword(keyword: str) -> str:
13
+ n = scpi_short_length(keyword)
14
+ return keyword[:n].upper() + keyword[n:].lower()
@@ -0,0 +1,4 @@
1
+ Metadata-Version: 2.4
2
+ Name: scpipy
3
+ Version: 0.1.0
4
+ Summary: Python SCPI I/O library with built-in client and server.
@@ -0,0 +1,26 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.py
4
+ src/scpipy/__init__.py
5
+ src/scpipy.egg-info/PKG-INFO
6
+ src/scpipy.egg-info/SOURCES.txt
7
+ src/scpipy.egg-info/dependency_links.txt
8
+ src/scpipy.egg-info/top_level.txt
9
+ src/scpipy/client/__init__.py
10
+ src/scpipy/client/client.py
11
+ src/scpipy/client/exceptions.py
12
+ src/scpipy/client/models.py
13
+ src/scpipy/client/worker.py
14
+ src/scpipy/server/__init__.py
15
+ src/scpipy/server/builtin_router.py
16
+ src/scpipy/server/context.py
17
+ src/scpipy/server/dispatcher.py
18
+ src/scpipy/server/exceptions.py
19
+ src/scpipy/server/routing.py
20
+ src/scpipy/server/server.py
21
+ src/scpipy/shared/__init__.py
22
+ src/scpipy/shared/ast.py
23
+ src/scpipy/shared/errors.py
24
+ src/scpipy/shared/exceptions.py
25
+ src/scpipy/shared/parser.py
26
+ src/scpipy/shared/utils.py
@@ -0,0 +1 @@
1
+ scpipy