shvcli 0.7.0__tar.gz → 0.8.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.
Files changed (48) hide show
  1. {shvcli-0.7.0/shvcli.egg-info → shvcli-0.8.0}/PKG-INFO +19 -3
  2. {shvcli-0.7.0 → shvcli-0.8.0}/README.rst +17 -0
  3. {shvcli-0.7.0 → shvcli-0.8.0}/pyproject.toml +4 -3
  4. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/__main__.py +1 -2
  5. shvcli-0.8.0/shvcli/__setuptools__.py +18 -0
  6. shvcli-0.8.0/shvcli/__version__.py +24 -0
  7. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/__init__.py +2 -0
  8. shvcli-0.8.0/shvcli/builtins/history.py +146 -0
  9. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/cli.py +1 -1
  10. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/client.py +5 -9
  11. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/cliitems.py +5 -2
  12. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/file.py +12 -9
  13. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/lsdir.py +2 -1
  14. shvcli-0.8.0/shvcli/oauth.py +75 -0
  15. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/options.py +1 -1
  16. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/state.py +5 -3
  17. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/tools/complet.py +3 -3
  18. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/tools/print.py +8 -3
  19. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/tree.py +15 -11
  20. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/url.py +80 -6
  21. {shvcli-0.7.0 → shvcli-0.8.0/shvcli.egg-info}/PKG-INFO +19 -3
  22. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli.egg-info/SOURCES.txt +3 -0
  23. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli.egg-info/requires.txt +1 -1
  24. shvcli-0.7.0/shvcli/__version__.py +0 -26
  25. {shvcli-0.7.0 → shvcli-0.8.0}/LICENSE +0 -0
  26. {shvcli-0.7.0 → shvcli-0.8.0}/setup.cfg +0 -0
  27. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/__init__.py +0 -0
  28. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/afterconn.py +0 -0
  29. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/args.py +0 -0
  30. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtin.py +0 -0
  31. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/broker.py +0 -0
  32. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/cd.py +0 -0
  33. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/file.py +0 -0
  34. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/help.py +0 -0
  35. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/scan.py +0 -0
  36. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/set.py +0 -0
  37. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/builtins/tree.py +0 -0
  38. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/complet.py +0 -0
  39. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/config.py +0 -0
  40. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/option.py +0 -0
  41. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/plugins.py +0 -0
  42. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/py.typed +0 -0
  43. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/scan.py +0 -0
  44. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/tools/__init__.py +0 -0
  45. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli/valid.py +0 -0
  46. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli.egg-info/dependency_links.txt +0 -0
  47. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli.egg-info/entry_points.txt +0 -0
  48. {shvcli-0.7.0 → shvcli-0.8.0}/shvcli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shvcli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Silicon Heaven CLI access application
5
5
  Author: Elektroline a.s.
6
6
  License: MIT License
@@ -27,7 +27,6 @@ License: MIT License
27
27
 
28
28
  Project-URL: repository, https://gitlab.com/silicon-heaven/shvcli
29
29
  Classifier: Development Status :: 4 - Beta
30
- Classifier: Programming Language :: Python :: 3
31
30
  Classifier: Operating System :: OS Independent
32
31
  Classifier: Environment :: Console
33
32
  Classifier: Intended Audience :: End Users/Desktop
@@ -37,7 +36,7 @@ Classifier: Topic :: Terminals
37
36
  Requires-Python: >=3.11
38
37
  Description-Content-Type: text/x-rst
39
38
  License-File: LICENSE
40
- Requires-Dist: pyshv>=0.9.0
39
+ Requires-Dist: pyshv>=0.10.0
41
40
  Requires-Dist: prompt_toolkit
42
41
  Requires-Dist: pygments
43
42
  Requires-Dist: pyxdg
@@ -151,6 +150,8 @@ following options are available without any plugins:
151
150
  is used only in case when method doesn't support delayed responses.
152
151
  * **autoget_timeout**: Timeout in seconds for call that is part of autoget
153
152
  functionality.
153
+ * **history_count**: Integer number used for number of records requested at time
154
+ for ``!history`` builtin.
154
155
 
155
156
  Example configuration file:
156
157
 
@@ -213,6 +214,10 @@ parameter must be path to the local file where data will be stored.
213
214
  **verify**: provides a way to verify RPC File node against local file. The
214
215
  parameter must be path to the local file used for the verification.
215
216
 
217
+ **history**: is helper to list recorded history of signals by ``.history`` API.
218
+ The listing is incremental in ``history_count`` configured number of records and
219
+ in the order from the newest ones.
220
+
216
221
 
217
222
  Special methods ``ls`` and ``dir``
218
223
  ----------------------------------
@@ -225,6 +230,17 @@ Their parameter is also handled in a special way. It is considered to be
225
230
  additional path suffix unless it is a valid CPON. This is allowed to match the
226
231
  common shells.
227
232
 
233
+ You can see ``raw`` option to disable this behavior.
234
+
235
+
236
+ OAuth2 login
237
+ ------------
238
+
239
+ SHVCLI supports non-standard login method OAuth2. This is not defined by SHV
240
+ standard. The non-standard URL query ``oauth2=y`` is accepted to perform the
241
+ OAuth2 login. The login is facilitated by web browser. SHVCLI will start server
242
+ on ``tcp://localhost:37719/`` to be able to receive token.
243
+
228
244
 
229
245
  Plugins
230
246
  -------
@@ -105,6 +105,8 @@ following options are available without any plugins:
105
105
  is used only in case when method doesn't support delayed responses.
106
106
  * **autoget_timeout**: Timeout in seconds for call that is part of autoget
107
107
  functionality.
108
+ * **history_count**: Integer number used for number of records requested at time
109
+ for ``!history`` builtin.
108
110
 
109
111
  Example configuration file:
110
112
 
@@ -167,6 +169,10 @@ parameter must be path to the local file where data will be stored.
167
169
  **verify**: provides a way to verify RPC File node against local file. The
168
170
  parameter must be path to the local file used for the verification.
169
171
 
172
+ **history**: is helper to list recorded history of signals by ``.history`` API.
173
+ The listing is incremental in ``history_count`` configured number of records and
174
+ in the order from the newest ones.
175
+
170
176
 
171
177
  Special methods ``ls`` and ``dir``
172
178
  ----------------------------------
@@ -179,6 +185,17 @@ Their parameter is also handled in a special way. It is considered to be
179
185
  additional path suffix unless it is a valid CPON. This is allowed to match the
180
186
  common shells.
181
187
 
188
+ You can see ``raw`` option to disable this behavior.
189
+
190
+
191
+ OAuth2 login
192
+ ------------
193
+
194
+ SHVCLI supports non-standard login method OAuth2. This is not defined by SHV
195
+ standard. The non-standard URL query ``oauth2=y`` is accepted to perform the
196
+ OAuth2 login. The login is facilitated by web browser. SHVCLI will start server
197
+ on ``tcp://localhost:37719/`` to be able to receive token.
198
+
182
199
 
183
200
  Plugins
184
201
  -------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "shvcli"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Silicon Heaven CLI access application"
5
5
  readme = "README.rst"
6
6
  license = {file = "LICENSE"}
@@ -9,7 +9,6 @@ authors = [
9
9
  ]
10
10
  classifiers = [
11
11
  "Development Status :: 4 - Beta",
12
- "Programming Language :: Python :: 3",
13
12
  "Operating System :: OS Independent",
14
13
  "Environment :: Console",
15
14
  "Intended Audience :: End Users/Desktop",
@@ -19,7 +18,7 @@ classifiers = [
19
18
  ]
20
19
  requires-python = ">=3.11"
21
20
  dependencies = [
22
- "pyshv >= 0.9.0",
21
+ "pyshv >= 0.10.0",
23
22
  "prompt_toolkit",
24
23
  "pygments",
25
24
  "pyxdg",
@@ -39,6 +38,8 @@ build-backend = "setuptools.build_meta"
39
38
  [tool.setuptools.packages.find]
40
39
  [tool.setuptools.package-data]
41
40
  "*" = ["py.typed"]
41
+ [tool.setuptools.cmdclass]
42
+ build_py = "shvcli.__setuptools__.BuildPy"
42
43
 
43
44
  [tool.coverage.run]
44
45
  branch = true
@@ -5,7 +5,6 @@ import asyncio
5
5
  from .afterconn import Afterconn
6
6
  from .args import args_apply, args_parse
7
7
  from .cli import run
8
- from .client import Client
9
8
  from .config import load_config
10
9
  from .plugins import load_plugins
11
10
  from .state import State
@@ -22,7 +21,7 @@ def main() -> None:
22
21
  state.cache_load(Url(state).cache_path())
23
22
 
24
23
  async def async_main() -> None:
25
- client = await Client.connect(Url(state).url, state=state)
24
+ client = await Url(state).connect()
26
25
  await Afterconn(state).run(client)
27
26
  await run(client)
28
27
  if client.client.connected:
@@ -0,0 +1,18 @@
1
+ """Setuptools hook used to replace __version__.py file."""
2
+
3
+ import pathlib
4
+
5
+ from setuptools.command.build_py import build_py as _build_py
6
+
7
+
8
+ class BuildPy(_build_py):
9
+ """Custom package build step."""
10
+
11
+ def run(self) -> None: # noqa: D102
12
+ package = pathlib.Path(__file__).parent.name
13
+ buildlib = pathlib.Path(self.build_lib) / package
14
+ buildlib.mkdir(parents=True, exist_ok=True)
15
+ version = self.distribution.metadata.version
16
+ (buildlib / "__version__.py").write_text(f'VERSION = "{version}"\n')
17
+ super().run()
18
+ (buildlib / "__setuptools__.py").unlink()
@@ -0,0 +1,24 @@
1
+ """The loader of the version for source file only installation.
2
+
3
+ It looks for pyproject.toml file in upper directory and loads version from
4
+ there.
5
+
6
+ This file is overwritten during distribution build to contain only constant
7
+ VERSION. Please see __setuptools__.py for details.
8
+ """
9
+
10
+ import pathlib
11
+ import tomllib
12
+
13
+
14
+ def _get_version() -> str:
15
+ pyproject = pathlib.Path(__file__).parent.parent / "pyproject.toml"
16
+ if pyproject.exists():
17
+ with pyproject.open("rb") as f:
18
+ res = tomllib.load(f)["project"]["version"]
19
+ if isinstance(res, str):
20
+ return res
21
+ return "unknown"
22
+
23
+
24
+ VERSION = _get_version()
@@ -8,6 +8,7 @@ from .broker import BuiltinSubscribe, BuiltinSubscriptions, BuiltinUnsubscribe
8
8
  from .cd import BuiltinCD
9
9
  from .file import BuiltinDownload, BuiltinUpload, BuiltinVerify
10
10
  from .help import BuiltinHelp
11
+ from .history import BuiltinHistory
11
12
  from .scan import BuiltinScan
12
13
  from .set import BuiltinSet
13
14
  from .tree import BuiltinTree
@@ -16,6 +17,7 @@ __all__ = [
16
17
  "BuiltinCD",
17
18
  "BuiltinDownload",
18
19
  "BuiltinHelp",
20
+ "BuiltinHistory",
19
21
  "BuiltinScan",
20
22
  "BuiltinSet",
21
23
  "BuiltinSubscribe",
@@ -0,0 +1,146 @@
1
+ """The getLog builtin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import collections.abc
6
+ import contextlib
7
+ import datetime
8
+ import itertools
9
+
10
+ import shv
11
+ import shv.path
12
+ import shv.rpcdef
13
+ from prompt_toolkit import Application
14
+ from prompt_toolkit.key_binding import KeyBindings
15
+ from prompt_toolkit.key_binding.key_processor import KeyPressEvent
16
+ from prompt_toolkit.layout import FormattedTextControl, Layout, Window
17
+ from prompt_toolkit.patch_stdout import patch_stdout
18
+
19
+ from ..builtin import Builtin, Builtins
20
+ from ..client import Client
21
+ from ..cliitems import CliItems
22
+ from ..option import IntOption
23
+ from ..state import State
24
+ from ..tools.print import print_cpon, print_ftext
25
+
26
+ _kb = KeyBindings()
27
+
28
+
29
+ @_kb.add("<any>")
30
+ def _(event: KeyPressEvent) -> None:
31
+ event.app.exit(result=event.key_sequence[0].key in {"c-m", "y"})
32
+
33
+
34
+ class BuiltinHistory(Builtin):
35
+ """The implementation of ``history`` builtin that fetches logs."""
36
+
37
+ def __init__(self, builtins: Builtins, state: State) -> None:
38
+ super().__init__(builtins, state)
39
+ builtins["history"] = self
40
+
41
+ @property
42
+ def description(self) -> tuple[str, str]: # noqa: D102
43
+ return "[SINCE]", "Get the node's history."
44
+
45
+ @staticmethod
46
+ async def _locate_getlog(
47
+ path: shv.path.SHVPath, client: Client
48
+ ) -> shv.path.SHVPath | None:
49
+ for pth in itertools.chain(reversed(path.parents), iter((path,))):
50
+ hpth = pth / ".history" / (path.relative_to(pth))
51
+ with contextlib.suppress(shv.rpcdef.RpcMethodNotFoundError):
52
+ if await client.dir_exists(hpth, "getLog"):
53
+ return hpth
54
+ return None
55
+
56
+ async def run(self, items: CliItems, client: Client) -> None: # noqa: D102
57
+ if items.param:
58
+ since = datetime.datetime.fromisoformat(items.param)
59
+ since = since.replace(tzinfo=datetime.UTC)
60
+ else:
61
+ since = datetime.datetime.now()
62
+ until = datetime.datetime.fromtimestamp(0)
63
+ count = HistoryCount(self.state)
64
+
65
+ # First we need to locate the appropriate .history
66
+ history_path = await self._locate_getlog(items.path, client)
67
+ if history_path is None:
68
+ print(f"There are no logs for path: {items.path}")
69
+ return
70
+
71
+ app: Application[bool] = Application(
72
+ Layout(Window(FormattedTextControl("Fetch more logs? (Y/n)"))),
73
+ key_bindings=_kb,
74
+ )
75
+
76
+ cont = True
77
+ while cont:
78
+ logs = await client.call(
79
+ history_path, "getLog", {1: since, 2: until, 3: int(count)}
80
+ )
81
+ if not shv.is_shvlist(logs) or not logs:
82
+ break # No more logs
83
+
84
+ refmap: dict[int, str] = {}
85
+ for i, log in enumerate(logs):
86
+ if shv.is_shvimap(log):
87
+ timestamp = log.get(1, None)
88
+ loc = None
89
+ if isinstance(ref := log.get(2), int):
90
+ loc = refmap.pop(i - ref - 1, None)
91
+ if loc is not None:
92
+ refmap[i] = loc
93
+ else:
94
+ spath = log.get(3, "")
95
+ method = log.get(5, "get")
96
+ signal = log.get(4, "chng")
97
+ if all(isinstance(s, str) for s in (spath, method, signal)):
98
+ loc = f"{spath}:{method}:{signal}"
99
+ refmap[i] = loc
100
+ if isinstance(timestamp, datetime.datetime) and loc is not None:
101
+ print_ftext(
102
+ (
103
+ (
104
+ "ansibrightblack",
105
+ timestamp.replace(tzinfo=None).isoformat(
106
+ timespec="milliseconds"
107
+ )
108
+ + "Z",
109
+ ),
110
+ ("", " "),
111
+ ("ansiblue", loc),
112
+ ),
113
+ end=" ",
114
+ )
115
+ print_cpon(log[6], short=True)
116
+ since = timestamp
117
+ cont = True
118
+ continue
119
+ print_cpon(log, short=True)
120
+ cont = False
121
+
122
+ if cont:
123
+ with patch_stdout():
124
+ cont = await app.run_async()
125
+ app.output.cursor_up(1)
126
+ app.output.write("\r")
127
+ app.output.erase_end_of_line()
128
+ app.output.flush()
129
+
130
+
131
+ class HistoryCount(IntOption):
132
+ """Number of items fetched at one ``!history`` builtin invocation."""
133
+
134
+ def __init__(self, state: State) -> None:
135
+ super().__init__(state)
136
+ self._value = 20
137
+
138
+ @classmethod
139
+ def aliases(cls) -> collections.abc.Sequence[str]: # noqa: D102
140
+ return ("history_count",)
141
+
142
+ def __int__(self) -> int:
143
+ return self._value
144
+
145
+ def rset(self, value: int) -> None: # noqa: D102
146
+ self._value = value
@@ -14,7 +14,7 @@ from prompt_toolkit.key_binding.key_processor import KeyPressEvent
14
14
  from prompt_toolkit.patch_stdout import patch_stdout
15
15
  from prompt_toolkit.shortcuts import CompleteStyle, ProgressBar, ProgressBarCounter
16
16
  from prompt_toolkit.shortcuts.progress_bar import formatters
17
- from shv import RpcError
17
+ from shv.rpcdef import RpcError
18
18
 
19
19
  from .builtin import Builtins
20
20
  from .client import Client
@@ -4,15 +4,11 @@ from __future__ import annotations
4
4
 
5
5
  import typing
6
6
 
7
- from shv import (
8
- RpcDir,
9
- RpcError,
10
- RpcMessage,
11
- RpcMethodNotFoundError,
12
- SHVClient,
13
- SHVPath,
14
- SHVType,
15
- )
7
+ from shv import SHVType
8
+ from shv.path import SHVPath
9
+ from shv.rpcapi.client import SHVClient
10
+ from shv.rpcdef import RpcDir, RpcError, RpcMethodNotFoundError
11
+ from shv.rpcmessage import RpcMessage
16
12
 
17
13
  from . import VERSION
18
14
  from .options import CallQueryTimeout, CallRetryTimeout
@@ -3,8 +3,9 @@
3
3
  import contextlib
4
4
  import string
5
5
 
6
- from shv import SHVPath, SHVType
6
+ from shv import SHVType
7
7
  from shv.cpon import Cpon
8
+ from shv.path import SHVPath
8
9
  from shv.rpctypes import RpcType, RpcTypeParseError, rpctype_parse
9
10
 
10
11
 
@@ -29,7 +30,9 @@ class CliItems:
29
30
  path = "" if ":" not in ri and "/" not in ri else ri.partition(":")[0]
30
31
  if any(c in path for c in string.whitespace):
31
32
  raise ValueError("Path can't have white character in it.")
32
- return self.path_prefix / path
33
+ if path == "/":
34
+ return SHVPath("")
35
+ return self.path_prefix / path.rstrip("/")
33
36
 
34
37
  @property
35
38
  def method(self) -> str:
@@ -7,15 +7,18 @@ import io
7
7
  import pathlib
8
8
 
9
9
  import shv
10
+ import shv.path
11
+ import shv.rpcdef.file
10
12
  from prompt_toolkit.shortcuts import ProgressBar, ProgressBarCounter
13
+ from shv.rpcdef.file import RpcFile
11
14
 
12
15
  from .client import Client
13
16
 
14
17
 
15
18
  async def copy_file(
16
19
  client: Client,
17
- src: pathlib.Path | shv.SHVPath,
18
- dest: pathlib.Path | shv.SHVPath,
20
+ src: pathlib.Path | shv.path.SHVPath,
21
+ dest: pathlib.Path | shv.path.SHVPath,
19
22
  label: str = "",
20
23
  ) -> None:
21
24
  """Copy file with support for local and SHV RPC files.
@@ -23,17 +26,17 @@ async def copy_file(
23
26
  :raises RpcError: For RPC errors.
24
27
  :raises FileNotFoundError: In case remote or local file is not located.
25
28
  """
26
- srcfile: shv.RpcFile | io.IOBase | None = None
27
- destfile: shv.RpcFile | io.IOBase | None = None
29
+ srcfile: RpcFile | io.IOBase | None = None
30
+ destfile: RpcFile | io.IOBase | None = None
28
31
 
29
32
  try:
30
- if isinstance(src, shv.SHVPath):
31
- srcfile = shv.RpcFile(client, str(src), flags=shv.RpcFile.Flag(0))
33
+ if isinstance(src, shv.path.SHVPath):
34
+ srcfile = RpcFile(client, str(src), flags=RpcFile.Flag(0))
32
35
  if not await srcfile.readable():
33
36
  raise FileNotFoundError(f"No such readable SHV RPC file: {src}")
34
37
  srcsiz = await srcfile.size()
35
- if isinstance(dest, shv.SHVPath):
36
- destfile = shv.RpcFile(client, str(dest), flags=shv.RpcFile.Flag(0))
38
+ if isinstance(dest, shv.path.SHVPath):
39
+ destfile = RpcFile(client, str(dest), flags=RpcFile.Flag(0))
37
40
  if not await destfile.writable():
38
41
  raise FileNotFoundError(f"No such writable SHV RPC file: {dest}")
39
42
 
@@ -44,7 +47,7 @@ async def copy_file(
44
47
  destfile = dest.open("wb")
45
48
 
46
49
  assert srcfile is not None and destfile is not None
47
- if isinstance(destfile, shv.RpcFile):
50
+ if isinstance(destfile, RpcFile):
48
51
  await destfile.truncate(0)
49
52
 
50
53
  with ProgressBar() as pb:
@@ -5,7 +5,8 @@ import contextlib
5
5
  import itertools
6
6
  import string
7
7
 
8
- from shv import Cpon, RpcDir, RpcError
8
+ from shv.cpon import Cpon
9
+ from shv.rpcdef import RpcDir, RpcError
9
10
 
10
11
  from .client import Client, Node
11
12
  from .cliitems import CliItems
@@ -0,0 +1,75 @@
1
+ """Utility to receive token from OAuth2 provider."""
2
+
3
+ import http.server
4
+ import json
5
+ import socketserver
6
+ import urllib.parse
7
+ import urllib.request
8
+ import webbrowser
9
+
10
+
11
+ def oauth_login_token(
12
+ tp: str, client_id: str, authorize_url: str, token_url: str, scopes: list[str]
13
+ ) -> str:
14
+ """Use OAuth2 to get login token.
15
+
16
+ This is blocking call and thus :func:`asyncio.run_in_executor` should be
17
+ used if called from asyncio.
18
+ """
19
+ redirect_uri = "http://localhost:37719/"
20
+
21
+ auth_code = None
22
+
23
+ class _HTTPHandler(http.server.BaseHTTPRequestHandler):
24
+ def do_GET(self) -> None: # noqa: N802
25
+ nonlocal auth_code
26
+ parsed_path = urllib.parse.urlparse(self.path)
27
+ if parsed_path.path == "/":
28
+ params = urllib.parse.parse_qs(parsed_path.query)
29
+ auth_code = params.get("code", [None])[0]
30
+ self.send_response(200)
31
+ self.send_header("Content-Type", "text/html")
32
+ self.end_headers()
33
+ self.wfile.write(b"""
34
+ <html><body>
35
+ <p>Authorization complete. You may close this window.</p>
36
+ <script>window.close();</script>
37
+ </body></html>
38
+ """)
39
+ else:
40
+ self.send_error(404)
41
+
42
+ def log_message(self, fmt: str, *args: str) -> None:
43
+ pass # suppress default logging
44
+
45
+ socketserver.TCPServer.allow_reuse_address = True
46
+ with socketserver.TCPServer(("", 37719), _HTTPHandler) as httpd:
47
+ webbrowser.open(
48
+ authorize_url
49
+ + "?"
50
+ + urllib.parse.urlencode({
51
+ "client_id": client_id,
52
+ "response_type": "code",
53
+ "redirect_uri": redirect_uri,
54
+ "scope": " ".join(scopes),
55
+ })
56
+ )
57
+ print("Waiting for authorization...")
58
+ while auth_code is None:
59
+ httpd.handle_request()
60
+
61
+ data = {
62
+ "client_id": client_id,
63
+ "grant_type": "authorization_code",
64
+ "code": auth_code,
65
+ "redirect_uri": redirect_uri,
66
+ "scope": " ".join(scopes),
67
+ }
68
+ req = urllib.request.Request( # noqa: S310
69
+ token_url, data=urllib.parse.urlencode(data).encode("UTF-8"), method="POST"
70
+ )
71
+ req.add_header("Content-Type", "application/x-www-form-urlencoded")
72
+ with urllib.request.urlopen(req) as resp: # noqa: S310
73
+ res = json.loads(resp.read())["access_token"]
74
+ assert isinstance(res, str)
75
+ return f"{tp}:{res}"
@@ -78,7 +78,7 @@ class DebugOption(BoolOption):
78
78
  logging.root.setLevel(logging.DEBUG if value else logging.WARNING)
79
79
 
80
80
  def __bool__(self) -> bool:
81
- return logging.root.level >= logging.DEBUG
81
+ return logging.root.level <= logging.DEBUG
82
82
 
83
83
 
84
84
  class CallDuration(BoolOption):
@@ -10,6 +10,8 @@ import pathlib
10
10
  import typing
11
11
 
12
12
  import shv
13
+ import shv.chainpack
14
+ import shv.path
13
15
 
14
16
 
15
17
  class StateVarMeta(abc.ABCMeta):
@@ -67,7 +69,7 @@ class State(dict[type[StateVar], StateVar]):
67
69
 
68
70
  def __init__(self) -> None:
69
71
  super().__init__()
70
- self.path: shv.SHVPath = shv.SHVPath()
72
+ self.path = shv.path.SHVPath()
71
73
  """Current path we are working relative to."""
72
74
 
73
75
  for cls in StateVar.variables:
@@ -79,7 +81,7 @@ class State(dict[type[StateVar], StateVar]):
79
81
  with contextlib.suppress(FileNotFoundError):
80
82
  with cachefile.open("rb") as f:
81
83
  try:
82
- data = shv.ChainPack.unpack(f.read())
84
+ data = shv.chainpack.ChainPack.unpack(f.read())
83
85
  except (ValueError, EOFError):
84
86
  return
85
87
  if shv.is_shvmap(data):
@@ -93,4 +95,4 @@ class State(dict[type[StateVar], StateVar]):
93
95
  var.cache_dump(data)
94
96
  cachefile.parent.mkdir(parents=True, exist_ok=True)
95
97
  with cachefile.open("wb") as f:
96
- f.write(shv.ChainPack.pack(data))
98
+ f.write(shv.chainpack.ChainPack.pack(data))
@@ -3,7 +3,7 @@
3
3
  import collections.abc
4
4
  import itertools
5
5
 
6
- import shv
6
+ import shv.path
7
7
  from prompt_toolkit.completion import Completion
8
8
 
9
9
  from ..tree import Tree
@@ -21,7 +21,7 @@ def comp_from(
21
21
 
22
22
 
23
23
  def comp_path(
24
- path: str, path_prefix: shv.SHVPath, tree: Tree, tail: str = ":"
24
+ path: str, path_prefix: shv.path.SHVPath, tree: Tree, tail: str = ":"
25
25
  ) -> collections.abc.Iterable[Completion]:
26
26
  """Perform completion for the given current path."""
27
27
  pth, _, word = path.rpartition("/")
@@ -36,7 +36,7 @@ def comp_path(
36
36
 
37
37
 
38
38
  def comp_signal_ri(
39
- ri: str, path_prefix: shv.SHVPath, tree: Tree
39
+ ri: str, path_prefix: shv.path.SHVPath, tree: Tree
40
40
  ) -> collections.abc.Iterable[Completion]:
41
41
  """Completion for RPC RI."""
42
42
  match ri.count(":"):
@@ -8,6 +8,7 @@ import typing
8
8
 
9
9
  import more_itertools
10
10
  import shv
11
+ import shv.cpon
11
12
  from prompt_toolkit import print_formatted_text
12
13
  from prompt_toolkit.document import Document
13
14
  from prompt_toolkit.formatted_text import FormattedText
@@ -18,9 +19,11 @@ from pygments.lexers.data import JsonLexer
18
19
  def print_ftext(
19
20
  fstrs: collections.abc.Iterable[tuple[str, str]]
20
21
  | collections.abc.Iterator[tuple[str, str]],
22
+ *args: typing.Any, # noqa: ANN401
23
+ **kwargs: typing.Any, # noqa: ANN401
21
24
  ) -> None:
22
25
  """Call :meth:`print_formatted_text` with :class:`FormattedText`."""
23
- print_formatted_text(FormattedText(fstrs))
26
+ print_formatted_text(FormattedText(fstrs), *args, **kwargs)
24
27
 
25
28
 
26
29
  def print_flist(
@@ -125,12 +128,14 @@ def _wrap_cpon(cpon: str) -> collections.abc.Iterator[str]:
125
128
  def print_cpon(data: shv.SHVType, prefix: str = "", short: bool = False) -> None:
126
129
  """Print given data in CPON format."""
127
130
  if short:
128
- cpon = "\n".join(_wrap_cpon(prefix + shv.Cpon.pack(data)))[len(prefix) :]
131
+ cpon = "\n".join(_wrap_cpon(prefix + shv.cpon.Cpon.pack(data)))[len(prefix) :]
129
132
  print_ftext(itertools.chain(iter((("", prefix),)), cpon_ftext(cpon)))
130
133
  else:
131
134
  print_ftext(
132
135
  itertools.chain(
133
136
  iter((("", prefix),)),
134
- cpon_ftext(shv.Cpon.pack(data, shv.CponWriter.Options(indent=b" "))),
137
+ cpon_ftext(
138
+ shv.cpon.Cpon.pack(data, shv.cpon.CponWriter.Options(indent=b" "))
139
+ ),
135
140
  )
136
141
  )
@@ -7,6 +7,8 @@ import contextlib
7
7
  import typing
8
8
 
9
9
  import shv
10
+ import shv.path
11
+ import shv.rpcdef
10
12
 
11
13
  from .state import State, StateVar
12
14
 
@@ -20,9 +22,9 @@ class Node(collections.abc.Mapping[str, "Node"]):
20
22
 
21
23
  self.nodes: dict[str, Node] = {}
22
24
  """The child nodes of this node."""
23
- self.methods: dict[str, shv.RpcDir | None] = {
24
- "ls": shv.RpcDir.stdls(),
25
- "dir": shv.RpcDir.stddir(),
25
+ self.methods: dict[str, shv.rpcdef.RpcDir | None] = {
26
+ "ls": shv.rpcdef.RpcDir.stdls(),
27
+ "dir": shv.rpcdef.RpcDir.stddir(),
26
28
  }
27
29
  """The methods discovered to be associated with this method."""
28
30
  self.nodes_probed = False
@@ -42,10 +44,10 @@ class Node(collections.abc.Mapping[str, "Node"]):
42
44
  """Get number of child nodes."""
43
45
  return len(self.nodes)
44
46
 
45
- def valid_path(self, path: shv.SHVPath | str) -> Node:
47
+ def valid_path(self, path: shv.path.SHVPath | str) -> Node:
46
48
  """Add valid path relative to this node."""
47
49
  if isinstance(path, str):
48
- path = shv.SHVPath(path)
50
+ path = shv.path.SHVPath(path)
49
51
  node = self
50
52
  for n in path.parts:
51
53
  if n not in node:
@@ -53,10 +55,10 @@ class Node(collections.abc.Mapping[str, "Node"]):
53
55
  node = node[n]
54
56
  return node
55
57
 
56
- def invalid_path(self, path: shv.SHVPath | str) -> None:
58
+ def invalid_path(self, path: shv.path.SHVPath | str) -> None:
57
59
  """Invalidate path as not existent."""
58
60
  if isinstance(path, str):
59
- path = shv.SHVPath(path)
61
+ path = shv.path.SHVPath(path)
60
62
  pnode = None
61
63
  node = self
62
64
  for n in path.parts:
@@ -67,7 +69,7 @@ class Node(collections.abc.Mapping[str, "Node"]):
67
69
  if pnode is not None:
68
70
  pnode.nodes.pop(path.name)
69
71
 
70
- def get_node(self, path: shv.SHVPath) -> Node | None:
72
+ def get_node(self, path: shv.path.SHVPath) -> Node | None:
71
73
  """Get node on given path."""
72
74
  node = self
73
75
  for n in path.parts:
@@ -76,7 +78,9 @@ class Node(collections.abc.Mapping[str, "Node"]):
76
78
  node = node[n]
77
79
  return node
78
80
 
79
- def get_method(self, path: shv.SHVPath, method: str) -> shv.RpcDir | None:
81
+ def get_method(
82
+ self, path: shv.path.SHVPath, method: str
83
+ ) -> shv.rpcdef.RpcDir | None:
80
84
  """Get method from given path."""
81
85
  if (node := self.get_node(path)) is not None:
82
86
  return node.methods.get(method, None)
@@ -104,8 +108,8 @@ class Node(collections.abc.Mapping[str, "Node"]):
104
108
  if shv.is_shvmap(data["methods"]):
105
109
  for n, v in data["methods"].items():
106
110
  desc = None
107
- with contextlib.suppress(shv.RpcInvalidParamError, ValueError):
108
- desc = shv.RpcDir.from_shv(v)
111
+ with contextlib.suppress(shv.rpcdef.RpcInvalidParamError, ValueError):
112
+ desc = shv.rpcdef.RpcDir.from_shv(v)
109
113
  self.methods[n] = desc
110
114
  self.nodes_probed = bool(data["nodes_probed"])
111
115
  self.methods_probed = bool(data["methods_probed"])
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import asyncio
6
7
  import contextlib
8
+ import dataclasses
7
9
  import os
8
10
  import pathlib
9
11
  import re
@@ -12,13 +14,39 @@ import subprocess
12
14
  import typing
13
15
 
14
16
  import shv
17
+ import shv.rpcapi
18
+ import shv.rpclogin
19
+ import shv.rpctransport
20
+ import shv.rpcurl
15
21
  import xdg.BaseDirectory
16
22
 
17
23
  from .args import ArgsParseFuncGenT, register_argparser
24
+ from .client import Client
18
25
  from .config import Config, ConfigError, register_config
26
+ from .oauth import oauth_login_token
19
27
  from .state import State, StateVar
20
28
 
21
29
 
30
+ class RpcUrlEx(shv.rpcurl.RpcUrl):
31
+ """Extended :class:`shv.rpcurl.RpcUrl`.
32
+
33
+ This defines additional attributes custom for the SHVCLI.
34
+ """
35
+
36
+ oauth2: bool = False
37
+ """If OAuth2 login method should be used or not."""
38
+
39
+ def _parse_query(self, pqs: dict[str, list[str]]) -> None:
40
+ super()._parse_query(pqs)
41
+ if opts := pqs.pop("oauth2", []):
42
+ if opts[0] in {"true", "t", "y"}:
43
+ self.oauth2 = True
44
+ elif opts[0] in {"false", "f", "n"}:
45
+ self.oauth2 = False
46
+ else:
47
+ raise ValueError(f"Invalid for verify: {opts[0]}")
48
+
49
+
22
50
  class Url(StateVar):
23
51
  """The SHV RPC URL deduction and store."""
24
52
 
@@ -28,8 +56,9 @@ class Url(StateVar):
28
56
  """The mapping of hosts aliases to the full URLs."""
29
57
  self.hosts_shell: dict[str, str] = {}
30
58
  """The mapping of aliases to the shell expanded URLs."""
31
- self.url: shv.RpcUrl = shv.RpcUrl("localhost")
59
+ self.url: RpcUrlEx = RpcUrlEx("localhost")
32
60
  """SHV RPC URL currectly set as the one to be used."""
61
+ self._state = state
33
62
 
34
63
  def set(self, value: str) -> None:
35
64
  """Set given value as URL by interpreting it in various ways.
@@ -55,27 +84,72 @@ class Url(StateVar):
55
84
  check=True,
56
85
  ).stdout.decode()
57
86
  value = self.hosts.get(value, value)
58
- self.url = shv.RpcUrl.parse(value)
87
+ self.url = RpcUrlEx.parse(value)
59
88
  # TODO this modifies it even if unix is explicitly specified
60
- if self.url.protocol is shv.RpcProtocol.UNIX:
89
+ if self.url.protocol is shv.rpcurl.RpcProtocol.UNIX:
61
90
  with contextlib.suppress(FileNotFoundError):
62
91
  st = os.stat(self.url.location)
63
92
  if stat.S_ISCHR(st.st_mode):
64
- self.url.protocol = shv.RpcProtocol.TTY
93
+ self.url.protocol = shv.rpcurl.RpcProtocol.TTY
65
94
 
66
95
  def cache_path(self) -> pathlib.Path:
67
96
  """Cache path based on the current URL."""
68
97
  return pathlib.Path(xdg.BaseDirectory.save_cache_path("shvcli")) / re.sub(
69
98
  r"[^\w_. -]",
70
99
  "_",
71
- shv.RpcUrl(
100
+ RpcUrlEx(
72
101
  location=self.url.location,
73
102
  port=self.url.port,
74
103
  protocol=self.url.protocol,
75
- login=shv.RpcLogin(username=self.url.login.username),
104
+ login=shv.rpclogin.RpcLogin(username=self.url.login.username),
76
105
  ).to_url(),
77
106
  )
78
107
 
108
+ async def connect(self) -> Client:
109
+ """Create new client based on this URL."""
110
+ url = self.url
111
+ if self.url.oauth2:
112
+ base = shv.rpcapi.SHVBase(
113
+ await shv.rpctransport.connect_rpc_client(self.url)
114
+ )
115
+ workflows = await base.call("", "workflows")
116
+ await base.disconnect()
117
+ if shv.is_shvlist(workflows):
118
+ oauths = [
119
+ v
120
+ for v in workflows
121
+ if shv.is_shvmap(v)
122
+ and isinstance(tp := v.get("type"), str)
123
+ and tp.startswith("oauth2")
124
+ ]
125
+ if len(oauths) >= 1:
126
+ oauth = oauths[0]
127
+ tp = oauth.get("type")
128
+ client_id = oauth.get("clientId")
129
+ authorize_url = oauth.get("authorizeUrl")
130
+ token_url = oauth.get("tokenUrl")
131
+ scopes = oauth.get("scopes")
132
+ assert shv.is_shvlist(scopes)
133
+ token = await asyncio.get_running_loop().run_in_executor(
134
+ None,
135
+ lambda: oauth_login_token(
136
+ str(tp),
137
+ str(client_id),
138
+ str(authorize_url),
139
+ str(token_url),
140
+ list(str(v) for v in scopes),
141
+ ),
142
+ )
143
+ url = dataclasses.replace(
144
+ url,
145
+ login=dataclasses.replace(
146
+ url.login,
147
+ token=token,
148
+ login_type=shv.rpclogin.RpcLoginType.TOKEN,
149
+ ),
150
+ )
151
+ return await Client.connect(url, state=self._state)
152
+
79
153
 
80
154
  @register_config
81
155
  def _config(config: Config, state: State) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shvcli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Silicon Heaven CLI access application
5
5
  Author: Elektroline a.s.
6
6
  License: MIT License
@@ -27,7 +27,6 @@ License: MIT License
27
27
 
28
28
  Project-URL: repository, https://gitlab.com/silicon-heaven/shvcli
29
29
  Classifier: Development Status :: 4 - Beta
30
- Classifier: Programming Language :: Python :: 3
31
30
  Classifier: Operating System :: OS Independent
32
31
  Classifier: Environment :: Console
33
32
  Classifier: Intended Audience :: End Users/Desktop
@@ -37,7 +36,7 @@ Classifier: Topic :: Terminals
37
36
  Requires-Python: >=3.11
38
37
  Description-Content-Type: text/x-rst
39
38
  License-File: LICENSE
40
- Requires-Dist: pyshv>=0.9.0
39
+ Requires-Dist: pyshv>=0.10.0
41
40
  Requires-Dist: prompt_toolkit
42
41
  Requires-Dist: pygments
43
42
  Requires-Dist: pyxdg
@@ -151,6 +150,8 @@ following options are available without any plugins:
151
150
  is used only in case when method doesn't support delayed responses.
152
151
  * **autoget_timeout**: Timeout in seconds for call that is part of autoget
153
152
  functionality.
153
+ * **history_count**: Integer number used for number of records requested at time
154
+ for ``!history`` builtin.
154
155
 
155
156
  Example configuration file:
156
157
 
@@ -213,6 +214,10 @@ parameter must be path to the local file where data will be stored.
213
214
  **verify**: provides a way to verify RPC File node against local file. The
214
215
  parameter must be path to the local file used for the verification.
215
216
 
217
+ **history**: is helper to list recorded history of signals by ``.history`` API.
218
+ The listing is incremental in ``history_count`` configured number of records and
219
+ in the order from the newest ones.
220
+
216
221
 
217
222
  Special methods ``ls`` and ``dir``
218
223
  ----------------------------------
@@ -225,6 +230,17 @@ Their parameter is also handled in a special way. It is considered to be
225
230
  additional path suffix unless it is a valid CPON. This is allowed to match the
226
231
  common shells.
227
232
 
233
+ You can see ``raw`` option to disable this behavior.
234
+
235
+
236
+ OAuth2 login
237
+ ------------
238
+
239
+ SHVCLI supports non-standard login method OAuth2. This is not defined by SHV
240
+ standard. The non-standard URL query ``oauth2=y`` is accepted to perform the
241
+ OAuth2 login. The login is facilitated by web browser. SHVCLI will start server
242
+ on ``tcp://localhost:37719/`` to be able to receive token.
243
+
228
244
 
229
245
  Plugins
230
246
  -------
@@ -3,6 +3,7 @@ README.rst
3
3
  pyproject.toml
4
4
  shvcli/__init__.py
5
5
  shvcli/__main__.py
6
+ shvcli/__setuptools__.py
6
7
  shvcli/__version__.py
7
8
  shvcli/afterconn.py
8
9
  shvcli/args.py
@@ -14,6 +15,7 @@ shvcli/complet.py
14
15
  shvcli/config.py
15
16
  shvcli/file.py
16
17
  shvcli/lsdir.py
18
+ shvcli/oauth.py
17
19
  shvcli/option.py
18
20
  shvcli/options.py
19
21
  shvcli/plugins.py
@@ -34,6 +36,7 @@ shvcli/builtins/broker.py
34
36
  shvcli/builtins/cd.py
35
37
  shvcli/builtins/file.py
36
38
  shvcli/builtins/help.py
39
+ shvcli/builtins/history.py
37
40
  shvcli/builtins/scan.py
38
41
  shvcli/builtins/set.py
39
42
  shvcli/builtins/tree.py
@@ -1,4 +1,4 @@
1
- pyshv>=0.9.0
1
+ pyshv>=0.10.0
2
2
  prompt_toolkit
3
3
  pygments
4
4
  pyxdg
@@ -1,26 +0,0 @@
1
- """The smarter loader of the version.
2
-
3
- It identifies if this package is installed and returns version from egg info or
4
- it looks for pyproject.toml file in upper directory.
5
- """
6
-
7
- import importlib.metadata
8
- import pathlib
9
- import tomllib
10
-
11
-
12
- def _get_version() -> str:
13
- for dist_name in importlib.metadata.packages_distributions().get(__package__, []):
14
- dist = importlib.metadata.distribution(dist_name)
15
- if str(dist.locate_file(f"{__package__}/__version__.py")) == __file__:
16
- return dist.version
17
- pyproject = pathlib.Path(__file__).parent.parent / "pyproject.toml"
18
- if pyproject.exists():
19
- with pyproject.open("rb") as f:
20
- res = tomllib.load(f)["project"]["version"]
21
- if isinstance(res, str):
22
- return res
23
- return "unknown"
24
-
25
-
26
- VERSION = _get_version()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes