uictlapi 0.1.1__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.
- uictlapi-0.1.1/LICENSE +21 -0
- uictlapi-0.1.1/PKG-INFO +110 -0
- uictlapi-0.1.1/README.md +73 -0
- uictlapi-0.1.1/pyproject.toml +144 -0
- uictlapi-0.1.1/setup.cfg +4 -0
- uictlapi-0.1.1/src/uictlapi/__init__.py +5 -0
- uictlapi-0.1.1/src/uictlapi/cli.py +265 -0
- uictlapi-0.1.1/src/uictlapi.egg-info/PKG-INFO +110 -0
- uictlapi-0.1.1/src/uictlapi.egg-info/SOURCES.txt +12 -0
- uictlapi-0.1.1/src/uictlapi.egg-info/dependency_links.txt +1 -0
- uictlapi-0.1.1/src/uictlapi.egg-info/entry_points.txt +2 -0
- uictlapi-0.1.1/src/uictlapi.egg-info/requires.txt +8 -0
- uictlapi-0.1.1/src/uictlapi.egg-info/top_level.txt +1 -0
- uictlapi-0.1.1/tests/test_cli.py +166 -0
uictlapi-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Roman Akinfeev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
uictlapi-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uictlapi
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Curl-like CLI for the UniFi Controller / UniFi OS Web UI API, with login and CSRF handled for you.
|
|
5
|
+
Author-email: Roman Akinfeev <akinfold@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/akinfold/uictlapi
|
|
8
|
+
Project-URL: Repository, https://github.com/akinfold/uictlapi
|
|
9
|
+
Project-URL: Issues, https://github.com/akinfold/uictlapi/issues
|
|
10
|
+
Keywords: unifi,ubiquiti,api,cli,networking,controller,docker
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Internet
|
|
24
|
+
Classifier: Topic :: System :: Networking
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: click>=8.0.0
|
|
30
|
+
Requires-Dist: requests>=2.25.0
|
|
31
|
+
Requires-Dist: requests-unifi-auth>=0.1.5
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
34
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
35
|
+
Requires-Dist: ruff>=0.8.0; extra == "test"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# uictlapi
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/uictlapi/)
|
|
41
|
+
[](https://hub.docker.com/r/akinfold/uictlapi)
|
|
42
|
+
[](https://github.com/akinfold/uictlapi/blob/main/LICENSE)
|
|
43
|
+
|
|
44
|
+
Curl-like CLI for the UniFi Controller / UniFi OS Web UI API — with login and CSRF
|
|
45
|
+
handled for you.
|
|
46
|
+
|
|
47
|
+
Auth and CSRF come from
|
|
48
|
+
[`requests-unifi-auth`](https://github.com/akinfold/requests-unifi-auth). This package is
|
|
49
|
+
only the HTTP CLI: any Web UI / proxy URL, any method. It is **not** a typed UniFi SDK and
|
|
50
|
+
does not invent domain commands (`routes apply`, inventory, multi-controller orchestration).
|
|
51
|
+
|
|
52
|
+
Live auth/CSRF compatibility against real controllers is tracked in
|
|
53
|
+
[`requests-unifi-auth` COMPATIBILITY.md](https://github.com/akinfold/requests-unifi-auth/blob/main/COMPATIBILITY.md).
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
### pip
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install uictlapi
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires `requests-unifi-auth>=0.1.5`.
|
|
64
|
+
|
|
65
|
+
### Docker
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
docker run --rm akinfold/uictlapi:latest --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Auth string: `user:pass@host` (or `@/path/to/file` containing the same).
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Read traffic routes (policy-based routing)
|
|
77
|
+
uictlapi get -a 'user:pass@192.168.1.1' --no-verify \
|
|
78
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/trafficroutes'
|
|
79
|
+
|
|
80
|
+
# Same via Docker
|
|
81
|
+
docker run --rm akinfold/uictlapi:latest get -a 'user:pass@192.168.1.1' --no-verify \
|
|
82
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/trafficroutes'
|
|
83
|
+
|
|
84
|
+
# POST JSON body (from string or @file)
|
|
85
|
+
uictlapi post -a 'user:pass@192.168.1.1' --no-verify \
|
|
86
|
+
-j '{"enabled":true}' \
|
|
87
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/some-endpoint'
|
|
88
|
+
|
|
89
|
+
uictlapi --version
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Common flags mirror curl-ish habits: `-H` / `-p` / `-d` / `-j` / `-o` / `--show-headers` /
|
|
93
|
+
`--status-only` / `--no-verify` / `-t`. Exit status `1` on HTTP ≥ 400, `2` on transport errors.
|
|
94
|
+
|
|
95
|
+
## Releasing
|
|
96
|
+
|
|
97
|
+
Version lives in `pyproject.toml` and `src/uictlapi/__init__.py`. Bump on `main` first
|
|
98
|
+
(GitHub Actions → **Bump version**, or locally with `bump-my-version`), then:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
git tag vX.Y.Z
|
|
102
|
+
git push origin vX.Y.Z
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The **Publish** workflow runs tests, uploads to PyPI, pushes multi-arch Docker images
|
|
106
|
+
(`X.Y.Z`, `X.Y`, and `latest` when appropriate), and creates a GitHub Release.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
uictlapi-0.1.1/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# uictlapi
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/uictlapi/)
|
|
4
|
+
[](https://hub.docker.com/r/akinfold/uictlapi)
|
|
5
|
+
[](https://github.com/akinfold/uictlapi/blob/main/LICENSE)
|
|
6
|
+
|
|
7
|
+
Curl-like CLI for the UniFi Controller / UniFi OS Web UI API — with login and CSRF
|
|
8
|
+
handled for you.
|
|
9
|
+
|
|
10
|
+
Auth and CSRF come from
|
|
11
|
+
[`requests-unifi-auth`](https://github.com/akinfold/requests-unifi-auth). This package is
|
|
12
|
+
only the HTTP CLI: any Web UI / proxy URL, any method. It is **not** a typed UniFi SDK and
|
|
13
|
+
does not invent domain commands (`routes apply`, inventory, multi-controller orchestration).
|
|
14
|
+
|
|
15
|
+
Live auth/CSRF compatibility against real controllers is tracked in
|
|
16
|
+
[`requests-unifi-auth` COMPATIBILITY.md](https://github.com/akinfold/requests-unifi-auth/blob/main/COMPATIBILITY.md).
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
### pip
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install uictlapi
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires `requests-unifi-auth>=0.1.5`.
|
|
27
|
+
|
|
28
|
+
### Docker
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
docker run --rm akinfold/uictlapi:latest --help
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Auth string: `user:pass@host` (or `@/path/to/file` containing the same).
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Read traffic routes (policy-based routing)
|
|
40
|
+
uictlapi get -a 'user:pass@192.168.1.1' --no-verify \
|
|
41
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/trafficroutes'
|
|
42
|
+
|
|
43
|
+
# Same via Docker
|
|
44
|
+
docker run --rm akinfold/uictlapi:latest get -a 'user:pass@192.168.1.1' --no-verify \
|
|
45
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/trafficroutes'
|
|
46
|
+
|
|
47
|
+
# POST JSON body (from string or @file)
|
|
48
|
+
uictlapi post -a 'user:pass@192.168.1.1' --no-verify \
|
|
49
|
+
-j '{"enabled":true}' \
|
|
50
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/some-endpoint'
|
|
51
|
+
|
|
52
|
+
uictlapi --version
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Common flags mirror curl-ish habits: `-H` / `-p` / `-d` / `-j` / `-o` / `--show-headers` /
|
|
56
|
+
`--status-only` / `--no-verify` / `-t`. Exit status `1` on HTTP ≥ 400, `2` on transport errors.
|
|
57
|
+
|
|
58
|
+
## Releasing
|
|
59
|
+
|
|
60
|
+
Version lives in `pyproject.toml` and `src/uictlapi/__init__.py`. Bump on `main` first
|
|
61
|
+
(GitHub Actions → **Bump version**, or locally with `bump-my-version`), then:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git tag vX.Y.Z
|
|
65
|
+
git push origin vX.Y.Z
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The **Publish** workflow runs tests, uploads to PyPI, pushes multi-arch Docker images
|
|
69
|
+
(`X.Y.Z`, `X.Y`, and `latest` when appropriate), and creates a GitHub Release.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel", "build"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uictlapi"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Curl-like CLI for the UniFi Controller / UniFi OS Web UI API, with login and CSRF handled for you."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{name = "Roman Akinfeev", email = "akinfold@gmail.com"}]
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"click>=8.0.0",
|
|
15
|
+
"requests>=2.25.0",
|
|
16
|
+
"requests-unifi-auth>=0.1.5",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["unifi", "ubiquiti", "api", "cli", "networking", "controller", "docker"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Environment :: Console",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Intended Audience :: System Administrators",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.8",
|
|
28
|
+
"Programming Language :: Python :: 3.9",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Topic :: Internet",
|
|
33
|
+
"Topic :: System :: Networking",
|
|
34
|
+
"Topic :: Utilities",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/akinfold/uictlapi"
|
|
39
|
+
Repository = "https://github.com/akinfold/uictlapi"
|
|
40
|
+
Issues = "https://github.com/akinfold/uictlapi/issues"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
uictlapi = "uictlapi.cli:cli"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
test = [
|
|
47
|
+
"pytest>=7.0.0",
|
|
48
|
+
"pytest-cov>=4.0.0",
|
|
49
|
+
"ruff>=0.8.0",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
where = ["src"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
pythonpath = ["src"]
|
|
58
|
+
python_files = ["test_*.py"]
|
|
59
|
+
python_classes = ["Test*"]
|
|
60
|
+
python_functions = ["test_*"]
|
|
61
|
+
addopts = [
|
|
62
|
+
"--strict-markers",
|
|
63
|
+
"--strict-config",
|
|
64
|
+
"-ra",
|
|
65
|
+
"--showlocals",
|
|
66
|
+
"--tb=short",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[tool.coverage.run]
|
|
70
|
+
source = ["src/"]
|
|
71
|
+
omit = [
|
|
72
|
+
"*/tests/*",
|
|
73
|
+
"*/test_*",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[tool.bumpversion]
|
|
77
|
+
current_version = "0.1.1"
|
|
78
|
+
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
|
79
|
+
serialize = ["{major}.{minor}.{patch}"]
|
|
80
|
+
search = "{current_version}"
|
|
81
|
+
replace = "{new_version}"
|
|
82
|
+
regex = false
|
|
83
|
+
ignore_missing_version = false
|
|
84
|
+
# Tag separately and push vX.Y.Z to trigger publish.yml
|
|
85
|
+
tag = false
|
|
86
|
+
sign_tags = false
|
|
87
|
+
tag_name = "v{new_version}"
|
|
88
|
+
tag_message = "Release v{new_version}"
|
|
89
|
+
allow_dirty = false
|
|
90
|
+
commit = true
|
|
91
|
+
message = "Bump version to {new_version}"
|
|
92
|
+
commit_args = ""
|
|
93
|
+
|
|
94
|
+
[[tool.bumpversion.files]]
|
|
95
|
+
filename = "src/uictlapi/__init__.py"
|
|
96
|
+
|
|
97
|
+
[[tool.bumpversion.files]]
|
|
98
|
+
filename = "pyproject.toml"
|
|
99
|
+
|
|
100
|
+
[tool.ruff]
|
|
101
|
+
exclude = [
|
|
102
|
+
".bzr",
|
|
103
|
+
".direnv",
|
|
104
|
+
".eggs",
|
|
105
|
+
".git",
|
|
106
|
+
".git-rewrite",
|
|
107
|
+
".hg",
|
|
108
|
+
".ipynb_checkpoints",
|
|
109
|
+
".mypy_cache",
|
|
110
|
+
".nox",
|
|
111
|
+
".pants.d",
|
|
112
|
+
".pyenv",
|
|
113
|
+
".pytest_cache",
|
|
114
|
+
".pytype",
|
|
115
|
+
".ruff_cache",
|
|
116
|
+
".svn",
|
|
117
|
+
".tox",
|
|
118
|
+
".venv",
|
|
119
|
+
".vscode",
|
|
120
|
+
"__pypackages__",
|
|
121
|
+
"_build",
|
|
122
|
+
"buck-out",
|
|
123
|
+
"build",
|
|
124
|
+
"dist",
|
|
125
|
+
"node_modules",
|
|
126
|
+
"site-packages",
|
|
127
|
+
"venv",
|
|
128
|
+
]
|
|
129
|
+
line-length = 88
|
|
130
|
+
indent-width = 4
|
|
131
|
+
target-version = "py38"
|
|
132
|
+
|
|
133
|
+
[tool.ruff.lint]
|
|
134
|
+
select = ["E4", "E7", "E9", "F"]
|
|
135
|
+
ignore = []
|
|
136
|
+
fixable = ["ALL"]
|
|
137
|
+
unfixable = []
|
|
138
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
139
|
+
|
|
140
|
+
[tool.ruff.format]
|
|
141
|
+
quote-style = "double"
|
|
142
|
+
indent-style = "space"
|
|
143
|
+
skip-magic-trailing-comma = false
|
|
144
|
+
line-ending = "auto"
|
uictlapi-0.1.1/setup.cfg
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
from typing import Tuple, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from requests_unifi_auth import UnifiControllerAuth
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
|
|
13
|
+
AUTH_REGEXP = re.compile(r'^(?P<username>[^:]+):(?P<password>[^@]+)@(?P<host>[^\s]+)\s*')
|
|
14
|
+
"""Match auth strings like 'foo:bar@192.168.1.1'."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_kv(pairs: Tuple[str, ...]) -> Dict[str, str]:
|
|
18
|
+
result: Dict[str, str] = {}
|
|
19
|
+
for p in pairs or ():
|
|
20
|
+
if "=" in p:
|
|
21
|
+
k, v = p.split("=", 1)
|
|
22
|
+
result[k.strip()] = v.strip()
|
|
23
|
+
else:
|
|
24
|
+
# allow header-like "Key: Value"
|
|
25
|
+
if ":" in p:
|
|
26
|
+
k, v = p.split(":", 1)
|
|
27
|
+
result[k.strip()] = v.strip()
|
|
28
|
+
else:
|
|
29
|
+
# fallback: treat as flag-like header with empty value
|
|
30
|
+
result[p.strip()] = ""
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_auth(auth: Optional[str]) -> Optional[Tuple[str, str, str]]:
|
|
35
|
+
if not auth:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if auth.startswith('@'):
|
|
39
|
+
# allow @filename to load auth data from file
|
|
40
|
+
path = auth[1:]
|
|
41
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
42
|
+
auth = f.read()
|
|
43
|
+
|
|
44
|
+
match = AUTH_REGEXP.match(auth)
|
|
45
|
+
if match:
|
|
46
|
+
user, password, host = match.groups()
|
|
47
|
+
return user, password, host
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _merge_params(params_list: Tuple[str, ...]) -> Dict[str, str]:
|
|
53
|
+
return _parse_kv(params_list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _load_json(json_text: Optional[str]) -> Optional[object]:
|
|
57
|
+
if not json_text:
|
|
58
|
+
return None
|
|
59
|
+
# allow @filename to load json from file
|
|
60
|
+
if json_text.startswith("@"):
|
|
61
|
+
path = json_text[1:]
|
|
62
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
63
|
+
return json.load(f)
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(json_text)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
# treat as plain string
|
|
68
|
+
return json_text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _request(
|
|
72
|
+
method: str,
|
|
73
|
+
url: str,
|
|
74
|
+
headers: Dict[str, str],
|
|
75
|
+
params: Dict[str, str],
|
|
76
|
+
data: List[str],
|
|
77
|
+
json_body,
|
|
78
|
+
auth: Tuple[str, str, str],
|
|
79
|
+
timeout: Optional[float],
|
|
80
|
+
allow_redirects: bool,
|
|
81
|
+
verify: bool,
|
|
82
|
+
):
|
|
83
|
+
kwargs = {
|
|
84
|
+
"headers": headers or None,
|
|
85
|
+
"params": params or None,
|
|
86
|
+
"timeout": timeout,
|
|
87
|
+
"allow_redirects": allow_redirects,
|
|
88
|
+
"verify": verify,
|
|
89
|
+
}
|
|
90
|
+
if auth:
|
|
91
|
+
kwargs["auth"] = UnifiControllerAuth(*auth)
|
|
92
|
+
if json_body is not None:
|
|
93
|
+
kwargs["json"] = json_body
|
|
94
|
+
elif data:
|
|
95
|
+
# if multiple data entries, send last one as body; for form data, user can pass key=value headers
|
|
96
|
+
if len(data) == 1:
|
|
97
|
+
v = data[0]
|
|
98
|
+
if v.startswith("@"):
|
|
99
|
+
with open(v[1:], "rb") as f:
|
|
100
|
+
kwargs["data"] = f.read()
|
|
101
|
+
else:
|
|
102
|
+
kwargs["data"] = v
|
|
103
|
+
else:
|
|
104
|
+
# multiple data entries -> send as form-encoded dict
|
|
105
|
+
kwargs["data"] = _parse_kv(tuple(data))
|
|
106
|
+
|
|
107
|
+
resp = requests.request(method=method, url=url, **kwargs)
|
|
108
|
+
return resp
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _print_response(resp: requests.Response, show_headers: bool, pretty: bool, output: Optional[str], status_only: bool):
|
|
112
|
+
if status_only:
|
|
113
|
+
click.echo(str(resp.status_code))
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if show_headers:
|
|
117
|
+
click.echo(f"HTTP/{resp.raw.version if hasattr(resp.raw, 'version') else '1.1'} {resp.status_code} {resp.reason}")
|
|
118
|
+
for k, v in resp.headers.items():
|
|
119
|
+
click.echo(f"{k}: {v}")
|
|
120
|
+
click.echo("")
|
|
121
|
+
|
|
122
|
+
content_type = resp.headers.get("Content-Type", "")
|
|
123
|
+
body_bytes = resp.content
|
|
124
|
+
|
|
125
|
+
if output:
|
|
126
|
+
mode = "wb"
|
|
127
|
+
with open(output, mode) as f:
|
|
128
|
+
f.write(body_bytes)
|
|
129
|
+
click.echo(f"Wrote response body to {output}")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# try to decode as text
|
|
133
|
+
try:
|
|
134
|
+
text = resp.text
|
|
135
|
+
except Exception:
|
|
136
|
+
text = None
|
|
137
|
+
|
|
138
|
+
if pretty and ("application/json" in content_type or (text and (text.strip().startswith("{") or text.strip().startswith("[")))):
|
|
139
|
+
try:
|
|
140
|
+
parsed = resp.json()
|
|
141
|
+
click.echo(json.dumps(parsed, indent=2, ensure_ascii=False))
|
|
142
|
+
return
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
# fallback to raw text or binary print as repr
|
|
147
|
+
if text is not None:
|
|
148
|
+
click.echo(text)
|
|
149
|
+
else:
|
|
150
|
+
click.echo(repr(body_bytes))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def common_options(func):
|
|
154
|
+
options = [
|
|
155
|
+
click.option("-H", "--header", multiple=True, help="Header, e.g. -H 'Accept: application/json' or -H 'X-Api-Key=VALUE'"),
|
|
156
|
+
click.option("-p", "--param", "params", multiple=True, help="Query param, e.g. -p 'key=value'"),
|
|
157
|
+
click.option("-d", "--data", "data", multiple=True, help="Request body or form field. Use @filename to read file"),
|
|
158
|
+
click.option("-j", "--json", "json_text", help="JSON body as string or @filename to read"),
|
|
159
|
+
click.option("-a", "--auth", help="Auth data as user:pass@host. For example: 'foo:bar@192.168.1.1'. Use @filename to read data from file."),
|
|
160
|
+
click.option("-t", "--timeout", type=float, default=30.0, show_default=True, help="Request timeout in seconds"),
|
|
161
|
+
click.option("--no-allow-redirects", "allow_redirects", flag_value=False, default=True, help="Disable redirects"),
|
|
162
|
+
click.option("--no-verify", "verify", flag_value=False, default=True, help="Disable SSL verification"),
|
|
163
|
+
click.option("-o", "--output", help="Write response body to file"),
|
|
164
|
+
click.option("--no-pretty", "pretty", flag_value=False, default=True, help="Disable pretty printing of JSON"),
|
|
165
|
+
click.option("--show-headers/--no-show-headers", default=False, help="Show response headers"),
|
|
166
|
+
click.option("--status-only", is_flag=True, default=False, help="Only print response HTTP status code"),
|
|
167
|
+
]
|
|
168
|
+
for opt in reversed(options):
|
|
169
|
+
func = opt(func)
|
|
170
|
+
return func
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@click.group()
|
|
174
|
+
@click.version_option(version=__version__, prog_name="uictlapi")
|
|
175
|
+
def cli():
|
|
176
|
+
"""Curl-like CLI for the UniFi Controller / UniFi OS Web UI API."""
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@cli.command()
|
|
181
|
+
@common_options
|
|
182
|
+
@click.argument("url")
|
|
183
|
+
def get(url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
184
|
+
"""HTTP GET"""
|
|
185
|
+
_run("GET", url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cli.command()
|
|
189
|
+
@common_options
|
|
190
|
+
@click.argument("url")
|
|
191
|
+
def post(url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
192
|
+
"""HTTP POST"""
|
|
193
|
+
_run("POST", url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command()
|
|
197
|
+
@common_options
|
|
198
|
+
@click.argument("url")
|
|
199
|
+
def put(url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
200
|
+
"""HTTP PUT"""
|
|
201
|
+
_run("PUT", url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@cli.command()
|
|
205
|
+
@common_options
|
|
206
|
+
@click.argument("url")
|
|
207
|
+
def delete(url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
208
|
+
"""HTTP DELETE"""
|
|
209
|
+
_run("DELETE", url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@cli.command()
|
|
213
|
+
@common_options
|
|
214
|
+
@click.argument("url")
|
|
215
|
+
def head(url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
216
|
+
"""HTTP HEAD"""
|
|
217
|
+
_run("HEAD", url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@cli.command()
|
|
221
|
+
@common_options
|
|
222
|
+
@click.argument("url")
|
|
223
|
+
def options(url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
224
|
+
"""HTTP OPTIONS"""
|
|
225
|
+
_run("OPTIONS", url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@cli.command()
|
|
229
|
+
@common_options
|
|
230
|
+
@click.argument("url")
|
|
231
|
+
def patch(url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
232
|
+
"""HTTP PATCH"""
|
|
233
|
+
_run("PATCH", url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _run(method, url, header, params, data, json_text, auth, timeout, allow_redirects, verify, output, pretty, show_headers, status_only):
|
|
237
|
+
headers = _parse_kv(header)
|
|
238
|
+
params_d = _merge_params(params)
|
|
239
|
+
json_body = _load_json(json_text)
|
|
240
|
+
auth_data = _parse_auth(auth)
|
|
241
|
+
try:
|
|
242
|
+
resp = _request(
|
|
243
|
+
method=method,
|
|
244
|
+
url=url,
|
|
245
|
+
headers=headers,
|
|
246
|
+
params=params_d,
|
|
247
|
+
data=list(data),
|
|
248
|
+
json_body=json_body,
|
|
249
|
+
auth=auth_data,
|
|
250
|
+
timeout=timeout,
|
|
251
|
+
allow_redirects=allow_redirects,
|
|
252
|
+
verify=verify,
|
|
253
|
+
)
|
|
254
|
+
except requests.RequestException as e:
|
|
255
|
+
click.echo(f"Request failed: {e}", err=True)
|
|
256
|
+
sys.exit(2)
|
|
257
|
+
|
|
258
|
+
_print_response(resp, show_headers=show_headers, pretty=pretty, output=output, status_only=status_only)
|
|
259
|
+
# exit with non-zero if status >= 400
|
|
260
|
+
if resp.status_code >= 400:
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
if __name__ == "__main__":
|
|
265
|
+
cli()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uictlapi
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Curl-like CLI for the UniFi Controller / UniFi OS Web UI API, with login and CSRF handled for you.
|
|
5
|
+
Author-email: Roman Akinfeev <akinfold@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/akinfold/uictlapi
|
|
8
|
+
Project-URL: Repository, https://github.com/akinfold/uictlapi
|
|
9
|
+
Project-URL: Issues, https://github.com/akinfold/uictlapi/issues
|
|
10
|
+
Keywords: unifi,ubiquiti,api,cli,networking,controller,docker
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Topic :: Internet
|
|
24
|
+
Classifier: Topic :: System :: Networking
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: click>=8.0.0
|
|
30
|
+
Requires-Dist: requests>=2.25.0
|
|
31
|
+
Requires-Dist: requests-unifi-auth>=0.1.5
|
|
32
|
+
Provides-Extra: test
|
|
33
|
+
Requires-Dist: pytest>=7.0.0; extra == "test"
|
|
34
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "test"
|
|
35
|
+
Requires-Dist: ruff>=0.8.0; extra == "test"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# uictlapi
|
|
39
|
+
|
|
40
|
+
[](https://pypi.org/project/uictlapi/)
|
|
41
|
+
[](https://hub.docker.com/r/akinfold/uictlapi)
|
|
42
|
+
[](https://github.com/akinfold/uictlapi/blob/main/LICENSE)
|
|
43
|
+
|
|
44
|
+
Curl-like CLI for the UniFi Controller / UniFi OS Web UI API — with login and CSRF
|
|
45
|
+
handled for you.
|
|
46
|
+
|
|
47
|
+
Auth and CSRF come from
|
|
48
|
+
[`requests-unifi-auth`](https://github.com/akinfold/requests-unifi-auth). This package is
|
|
49
|
+
only the HTTP CLI: any Web UI / proxy URL, any method. It is **not** a typed UniFi SDK and
|
|
50
|
+
does not invent domain commands (`routes apply`, inventory, multi-controller orchestration).
|
|
51
|
+
|
|
52
|
+
Live auth/CSRF compatibility against real controllers is tracked in
|
|
53
|
+
[`requests-unifi-auth` COMPATIBILITY.md](https://github.com/akinfold/requests-unifi-auth/blob/main/COMPATIBILITY.md).
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
### pip
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install uictlapi
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Requires `requests-unifi-auth>=0.1.5`.
|
|
64
|
+
|
|
65
|
+
### Docker
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
docker run --rm akinfold/uictlapi:latest --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Usage
|
|
72
|
+
|
|
73
|
+
Auth string: `user:pass@host` (or `@/path/to/file` containing the same).
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Read traffic routes (policy-based routing)
|
|
77
|
+
uictlapi get -a 'user:pass@192.168.1.1' --no-verify \
|
|
78
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/trafficroutes'
|
|
79
|
+
|
|
80
|
+
# Same via Docker
|
|
81
|
+
docker run --rm akinfold/uictlapi:latest get -a 'user:pass@192.168.1.1' --no-verify \
|
|
82
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/trafficroutes'
|
|
83
|
+
|
|
84
|
+
# POST JSON body (from string or @file)
|
|
85
|
+
uictlapi post -a 'user:pass@192.168.1.1' --no-verify \
|
|
86
|
+
-j '{"enabled":true}' \
|
|
87
|
+
'https://192.168.1.1/proxy/network/v2/api/site/default/some-endpoint'
|
|
88
|
+
|
|
89
|
+
uictlapi --version
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Common flags mirror curl-ish habits: `-H` / `-p` / `-d` / `-j` / `-o` / `--show-headers` /
|
|
93
|
+
`--status-only` / `--no-verify` / `-t`. Exit status `1` on HTTP ≥ 400, `2` on transport errors.
|
|
94
|
+
|
|
95
|
+
## Releasing
|
|
96
|
+
|
|
97
|
+
Version lives in `pyproject.toml` and `src/uictlapi/__init__.py`. Bump on `main` first
|
|
98
|
+
(GitHub Actions → **Bump version**, or locally with `bump-my-version`), then:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
git tag vX.Y.Z
|
|
102
|
+
git push origin vX.Y.Z
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The **Publish** workflow runs tests, uploads to PyPI, pushes multi-arch Docker images
|
|
106
|
+
(`X.Y.Z`, `X.Y`, and `latest` when appropriate), and creates a GitHub Release.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/uictlapi/__init__.py
|
|
5
|
+
src/uictlapi/cli.py
|
|
6
|
+
src/uictlapi.egg-info/PKG-INFO
|
|
7
|
+
src/uictlapi.egg-info/SOURCES.txt
|
|
8
|
+
src/uictlapi.egg-info/dependency_links.txt
|
|
9
|
+
src/uictlapi.egg-info/entry_points.txt
|
|
10
|
+
src/uictlapi.egg-info/requires.txt
|
|
11
|
+
src/uictlapi.egg-info/top_level.txt
|
|
12
|
+
tests/test_cli.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uictlapi
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from uictlapi import cli
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_parse_kv_basic():
|
|
6
|
+
res = cli._parse_kv(("a=1", "b: 2", "flag"))
|
|
7
|
+
assert res == {"a": "1", "b": "2", "flag": ""}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_parse_auth_direct_and_file(tmp_path):
|
|
11
|
+
assert cli._parse_auth("user:pass@host") == ("user", "pass", "host")
|
|
12
|
+
p = tmp_path / "auth.txt"
|
|
13
|
+
p.write_text("foo:bar@192.0.2.1")
|
|
14
|
+
assert cli._parse_auth("@" + str(p)) == ("foo", "bar", "192.0.2.1")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_parse_auth_invalid():
|
|
18
|
+
assert cli._parse_auth("invalidstring") is None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_load_json_string_and_file_and_plain(tmp_path):
|
|
22
|
+
assert cli._load_json('{"x":1}') == {"x": 1}
|
|
23
|
+
p = tmp_path / "data.json"
|
|
24
|
+
p.write_text('["a", 1]')
|
|
25
|
+
assert cli._load_json("@" + str(p)) == ["a", 1]
|
|
26
|
+
assert cli._load_json("not a json") == "not a json"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_request_uses_json_and_auth(monkeypatch):
|
|
30
|
+
captured = {}
|
|
31
|
+
|
|
32
|
+
def fake_request(method, url, **kwargs):
|
|
33
|
+
captured["method"] = method
|
|
34
|
+
captured["url"] = url
|
|
35
|
+
captured["kwargs"] = kwargs
|
|
36
|
+
|
|
37
|
+
class Dummy:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
return Dummy()
|
|
41
|
+
|
|
42
|
+
# make UnifiControllerAuth return a sentinel object
|
|
43
|
+
monkeypatch.setattr(cli, "UnifiControllerAuth", lambda u, p, h: ("AUTHOBJ", u, p, h))
|
|
44
|
+
monkeypatch.setattr(cli.requests, "request", fake_request)
|
|
45
|
+
|
|
46
|
+
cli._request(
|
|
47
|
+
method="POST",
|
|
48
|
+
url="http://example.test",
|
|
49
|
+
headers={"H": "v"},
|
|
50
|
+
params={"q": "1"},
|
|
51
|
+
data=[],
|
|
52
|
+
json_body={"a": 1},
|
|
53
|
+
auth=("u", "p", "h"),
|
|
54
|
+
timeout=5,
|
|
55
|
+
allow_redirects=True,
|
|
56
|
+
verify=False,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert captured["method"] == "POST"
|
|
60
|
+
assert captured["url"] == "http://example.test"
|
|
61
|
+
assert captured["kwargs"]["json"] == {"a": 1}
|
|
62
|
+
assert "data" not in captured["kwargs"]
|
|
63
|
+
assert captured["kwargs"]["auth"] == ("AUTHOBJ", "u", "p", "h")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_request_data_file_and_multiple(monkeypatch, tmp_path):
|
|
67
|
+
calls = []
|
|
68
|
+
|
|
69
|
+
def fake_request(method, url, **kwargs):
|
|
70
|
+
calls.append(kwargs)
|
|
71
|
+
|
|
72
|
+
class Dummy:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
return Dummy()
|
|
76
|
+
|
|
77
|
+
monkeypatch.setattr(cli, "UnifiControllerAuth", lambda u, p, h: None)
|
|
78
|
+
monkeypatch.setattr(cli.requests, "request", fake_request)
|
|
79
|
+
|
|
80
|
+
f = tmp_path / "bin.dat"
|
|
81
|
+
f.write_bytes(b"binarycontent")
|
|
82
|
+
|
|
83
|
+
# single data file -> bytes body
|
|
84
|
+
cli._request(
|
|
85
|
+
method="PUT",
|
|
86
|
+
url="http://x.test",
|
|
87
|
+
headers={},
|
|
88
|
+
params={},
|
|
89
|
+
data=[f"@{f}"],
|
|
90
|
+
json_body=None,
|
|
91
|
+
auth=None,
|
|
92
|
+
timeout=None,
|
|
93
|
+
allow_redirects=True,
|
|
94
|
+
verify=True,
|
|
95
|
+
)
|
|
96
|
+
assert calls[0]["data"] == b"binarycontent"
|
|
97
|
+
|
|
98
|
+
# multiple data entries -> dict
|
|
99
|
+
calls.clear()
|
|
100
|
+
cli._request(
|
|
101
|
+
method="PUT",
|
|
102
|
+
url="http://x.test",
|
|
103
|
+
headers={},
|
|
104
|
+
params={},
|
|
105
|
+
data=["k=1", "v:2"],
|
|
106
|
+
json_body=None,
|
|
107
|
+
auth=None,
|
|
108
|
+
timeout=None,
|
|
109
|
+
allow_redirects=True,
|
|
110
|
+
verify=True,
|
|
111
|
+
)
|
|
112
|
+
assert calls[0]["data"] == {"k": "1", "v": "2"}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _make_dummy_response(status=200, content=b'{"ok":true}', headers=None, reason="OK"):
|
|
116
|
+
class Raw:
|
|
117
|
+
version = "1.1"
|
|
118
|
+
|
|
119
|
+
class DummyResp:
|
|
120
|
+
def __init__(self):
|
|
121
|
+
self.status_code = status
|
|
122
|
+
self.reason = reason
|
|
123
|
+
self.headers = headers or {"Content-Type": "application/json"}
|
|
124
|
+
self.content = content
|
|
125
|
+
self.raw = Raw()
|
|
126
|
+
|
|
127
|
+
def json(self):
|
|
128
|
+
return json.loads(self.content.decode("utf-8"))
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def text(self):
|
|
132
|
+
try:
|
|
133
|
+
return self.content.decode("utf-8")
|
|
134
|
+
except Exception:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
return DummyResp()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_print_response_pretty_and_headers(capsys):
|
|
141
|
+
r = _make_dummy_response()
|
|
142
|
+
cli._print_response(r, show_headers=True, pretty=True, output=None, status_only=False)
|
|
143
|
+
out = capsys.readouterr().out
|
|
144
|
+
assert "HTTP/" in out
|
|
145
|
+
assert '"ok": true' in out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_print_response_output_and_status_only(tmp_path, capsys):
|
|
149
|
+
r = _make_dummy_response(content=b"binarydata", headers={"Content-Type": "application/octet-stream"})
|
|
150
|
+
out_file = tmp_path / "out.bin"
|
|
151
|
+
cli._print_response(r, show_headers=False, pretty=False, output=str(out_file), status_only=False)
|
|
152
|
+
assert out_file.read_bytes() == b"binarydata"
|
|
153
|
+
|
|
154
|
+
# status_only prints only status code
|
|
155
|
+
cli._print_response(_make_dummy_response(status=404, content=b""), show_headers=False, pretty=False, output=None, status_only=True)
|
|
156
|
+
out = capsys.readouterr().out
|
|
157
|
+
assert "404" in out
|
|
158
|
+
|
|
159
|
+
def test_cli_version():
|
|
160
|
+
from click.testing import CliRunner
|
|
161
|
+
from uictlapi import __version__
|
|
162
|
+
from uictlapi.cli import cli as cli_group
|
|
163
|
+
|
|
164
|
+
result = CliRunner().invoke(cli_group, ["--version"])
|
|
165
|
+
assert result.exit_code == 0
|
|
166
|
+
assert __version__ in result.output
|