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 +4 -0
- scpipy-0.1.0/README.md +31 -0
- scpipy-0.1.0/pyproject.toml +14 -0
- scpipy-0.1.0/setup.cfg +4 -0
- scpipy-0.1.0/setup.py +3 -0
- scpipy-0.1.0/src/scpipy/__init__.py +0 -0
- scpipy-0.1.0/src/scpipy/client/__init__.py +1 -0
- scpipy-0.1.0/src/scpipy/client/client.py +136 -0
- scpipy-0.1.0/src/scpipy/client/exceptions.py +10 -0
- scpipy-0.1.0/src/scpipy/client/models.py +17 -0
- scpipy-0.1.0/src/scpipy/client/worker.py +150 -0
- scpipy-0.1.0/src/scpipy/server/__init__.py +3 -0
- scpipy-0.1.0/src/scpipy/server/builtin_router.py +14 -0
- scpipy-0.1.0/src/scpipy/server/context.py +18 -0
- scpipy-0.1.0/src/scpipy/server/dispatcher.py +103 -0
- scpipy-0.1.0/src/scpipy/server/exceptions.py +14 -0
- scpipy-0.1.0/src/scpipy/server/routing.py +68 -0
- scpipy-0.1.0/src/scpipy/server/server.py +163 -0
- scpipy-0.1.0/src/scpipy/shared/__init__.py +1 -0
- scpipy-0.1.0/src/scpipy/shared/ast.py +23 -0
- scpipy-0.1.0/src/scpipy/shared/errors.py +48 -0
- scpipy-0.1.0/src/scpipy/shared/exceptions.py +2 -0
- scpipy-0.1.0/src/scpipy/shared/parser.py +205 -0
- scpipy-0.1.0/src/scpipy/shared/utils.py +14 -0
- scpipy-0.1.0/src/scpipy.egg-info/PKG-INFO +4 -0
- scpipy-0.1.0/src/scpipy.egg-info/SOURCES.txt +26 -0
- scpipy-0.1.0/src/scpipy.egg-info/dependency_links.txt +1 -0
- scpipy-0.1.0/src/scpipy.egg-info/top_level.txt +1 -0
scpipy-0.1.0/PKG-INFO
ADDED
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
scpipy-0.1.0/setup.py
ADDED
|
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,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,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,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,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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
scpipy
|