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 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.
@@ -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
+ [![PYPI](https://img.shields.io/pypi/v/uictlapi)](https://pypi.org/project/uictlapi/)
41
+ [![Docker Image](https://img.shields.io/docker/v/akinfold/uictlapi?label=docker&sort=semver)](https://hub.docker.com/r/akinfold/uictlapi)
42
+ [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](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,73 @@
1
+ # uictlapi
2
+
3
+ [![PYPI](https://img.shields.io/pypi/v/uictlapi)](https://pypi.org/project/uictlapi/)
4
+ [![Docker Image](https://img.shields.io/docker/v/akinfold/uictlapi?label=docker&sort=semver)](https://hub.docker.com/r/akinfold/uictlapi)
5
+ [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ __version__ = "0.1.1"
2
+
3
+ __all__ = [
4
+ "__version__",
5
+ ]
@@ -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
+ [![PYPI](https://img.shields.io/pypi/v/uictlapi)](https://pypi.org/project/uictlapi/)
41
+ [![Docker Image](https://img.shields.io/docker/v/akinfold/uictlapi?label=docker&sort=semver)](https://hub.docker.com/r/akinfold/uictlapi)
42
+ [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](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,2 @@
1
+ [console_scripts]
2
+ uictlapi = uictlapi.cli:cli
@@ -0,0 +1,8 @@
1
+ click>=8.0.0
2
+ requests>=2.25.0
3
+ requests-unifi-auth>=0.1.5
4
+
5
+ [test]
6
+ pytest>=7.0.0
7
+ pytest-cov>=4.0.0
8
+ ruff>=0.8.0
@@ -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