vancam 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vancam-0.1.0/LICENSE +21 -0
- vancam-0.1.0/PKG-INFO +141 -0
- vancam-0.1.0/README.md +91 -0
- vancam-0.1.0/pyproject.toml +47 -0
- vancam-0.1.0/setup.cfg +4 -0
- vancam-0.1.0/tests/test_cli.py +97 -0
- vancam-0.1.0/tests/test_client.py +174 -0
- vancam-0.1.0/vancam.egg-info/PKG-INFO +141 -0
- vancam-0.1.0/vancam.egg-info/SOURCES.txt +14 -0
- vancam-0.1.0/vancam.egg-info/dependency_links.txt +1 -0
- vancam-0.1.0/vancam.egg-info/entry_points.txt +2 -0
- vancam-0.1.0/vancam.egg-info/requires.txt +6 -0
- vancam-0.1.0/vancam.egg-info/top_level.txt +1 -0
- vancam-0.1.0/vancam_cli/__init__.py +3 -0
- vancam-0.1.0/vancam_cli/cli.py +157 -0
- vancam-0.1.0/vancam_cli/client.py +147 -0
vancam-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DD
|
|
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.
|
vancam-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vancam
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the Vancam camera spatial search API
|
|
5
|
+
Author: DD
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 DD
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://vancam.ai
|
|
29
|
+
Project-URL: Repository, https://github.com/shughestr/vancam-cli
|
|
30
|
+
Classifier: Environment :: Console
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
41
|
+
Requires-Python: >=3.9
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: requests>=2.31.0
|
|
45
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
46
|
+
Provides-Extra: test
|
|
47
|
+
Requires-Dist: pytest>=8.0.0; extra == "test"
|
|
48
|
+
Requires-Dist: responses>=0.25.0; extra == "test"
|
|
49
|
+
Dynamic: license-file
|
|
50
|
+
|
|
51
|
+
# Vancam CLI
|
|
52
|
+
|
|
53
|
+
**Real-world observation for AI agents.**
|
|
54
|
+
|
|
55
|
+
Command-line client for the Vancam camera spatial search API
|
|
56
|
+
(`api.vancam.ai`), giving developers and AI agents instant access to
|
|
57
|
+
**1M+ live public cameras across 100+ countries** — the same backend used
|
|
58
|
+
by the [MCP server](https://github.com/shughestr/mcp-vancam) and the
|
|
59
|
+
[vancam.ai](https://vancam.ai) web app.
|
|
60
|
+
|
|
61
|
+
Instead of relying on outdated web pages or user reports, Vancam provides
|
|
62
|
+
fresh visual evidence from the physical world, letting AI systems answer
|
|
63
|
+
location-based questions with confidence.
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
vancam REST API
|
|
67
|
+
├── vancam-mcp (MCP server)
|
|
68
|
+
├── vancam-cli (this project)
|
|
69
|
+
├── SDKs (future)
|
|
70
|
+
└── vancam.ai (web app)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install vancam
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Setup
|
|
80
|
+
|
|
81
|
+
No setup required to try it — defaults to a shared, public key if
|
|
82
|
+
`VANCAM_API_KEY` isn't set. That's **one key used by every anonymous
|
|
83
|
+
caller**, so its 1 req/s / 500 requests/month limit is a collective pool
|
|
84
|
+
shared across all of them, not 500 each. For a limit that's yours alone
|
|
85
|
+
(5 req/s, 500 requests/month, not shared), get a free personal key from your
|
|
86
|
+
account dashboard at [vancam.ai/account](https://vancam.ai/account):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cp .env.example .env
|
|
90
|
+
# edit .env, or export VANCAM_API_KEY directly
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# N nearest cameras to a point
|
|
97
|
+
vancam nearest --lat 49.2827 --lon -123.1207 --limit 5
|
|
98
|
+
|
|
99
|
+
# Cameras within a radius (km)
|
|
100
|
+
vancam radius --lat 49.2827 --lon -123.1207 --radius 1
|
|
101
|
+
|
|
102
|
+
# Cameras along a route
|
|
103
|
+
vancam route --origin-lat 49.2827 --origin-lon -123.1207 \
|
|
104
|
+
--dest-lat 49.2965 --dest-lon -123.0884 --buffer 100
|
|
105
|
+
|
|
106
|
+
# Cameras in a bounding box
|
|
107
|
+
vancam bounds --min-lat 49.2 --min-lon -123.2 --max-lat 49.3 --max-lon -123.0
|
|
108
|
+
|
|
109
|
+
# Single camera by ID
|
|
110
|
+
vancam get 30145
|
|
111
|
+
|
|
112
|
+
# Live image
|
|
113
|
+
vancam image 30145 # prints the image URL
|
|
114
|
+
vancam image 30145 -o camera.jpg # downloads it
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
All commands print JSON to stdout (`--compact` for single-line output),
|
|
118
|
+
so this composes with `jq`:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
vancam nearest --lat 49.2827 --lon -123.1207 --limit 1 | jq '.cameras[0].image_url'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Options
|
|
125
|
+
|
|
126
|
+
- `--api-key` — override `VANCAM_API_KEY`
|
|
127
|
+
- `--compact` — compact JSON instead of pretty-printed
|
|
128
|
+
|
|
129
|
+
## Use Cases
|
|
130
|
+
|
|
131
|
+
- Verify road and traffic conditions
|
|
132
|
+
- Monitor infrastructure and public spaces
|
|
133
|
+
- Check border crossings and transportation hubs
|
|
134
|
+
- Build AI agents with real-world awareness
|
|
135
|
+
- Power MCP servers, automation, and LLM applications
|
|
136
|
+
|
|
137
|
+
## Learn More
|
|
138
|
+
|
|
139
|
+
- **Website:** https://vancam.ai
|
|
140
|
+
- **API:** https://vancam.ai/api
|
|
141
|
+
- **MCP Server:** https://github.com/shughestr/mcp-vancam
|
vancam-0.1.0/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Vancam CLI
|
|
2
|
+
|
|
3
|
+
**Real-world observation for AI agents.**
|
|
4
|
+
|
|
5
|
+
Command-line client for the Vancam camera spatial search API
|
|
6
|
+
(`api.vancam.ai`), giving developers and AI agents instant access to
|
|
7
|
+
**1M+ live public cameras across 100+ countries** — the same backend used
|
|
8
|
+
by the [MCP server](https://github.com/shughestr/mcp-vancam) and the
|
|
9
|
+
[vancam.ai](https://vancam.ai) web app.
|
|
10
|
+
|
|
11
|
+
Instead of relying on outdated web pages or user reports, Vancam provides
|
|
12
|
+
fresh visual evidence from the physical world, letting AI systems answer
|
|
13
|
+
location-based questions with confidence.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
vancam REST API
|
|
17
|
+
├── vancam-mcp (MCP server)
|
|
18
|
+
├── vancam-cli (this project)
|
|
19
|
+
├── SDKs (future)
|
|
20
|
+
└── vancam.ai (web app)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install vancam
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Setup
|
|
30
|
+
|
|
31
|
+
No setup required to try it — defaults to a shared, public key if
|
|
32
|
+
`VANCAM_API_KEY` isn't set. That's **one key used by every anonymous
|
|
33
|
+
caller**, so its 1 req/s / 500 requests/month limit is a collective pool
|
|
34
|
+
shared across all of them, not 500 each. For a limit that's yours alone
|
|
35
|
+
(5 req/s, 500 requests/month, not shared), get a free personal key from your
|
|
36
|
+
account dashboard at [vancam.ai/account](https://vancam.ai/account):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
cp .env.example .env
|
|
40
|
+
# edit .env, or export VANCAM_API_KEY directly
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# N nearest cameras to a point
|
|
47
|
+
vancam nearest --lat 49.2827 --lon -123.1207 --limit 5
|
|
48
|
+
|
|
49
|
+
# Cameras within a radius (km)
|
|
50
|
+
vancam radius --lat 49.2827 --lon -123.1207 --radius 1
|
|
51
|
+
|
|
52
|
+
# Cameras along a route
|
|
53
|
+
vancam route --origin-lat 49.2827 --origin-lon -123.1207 \
|
|
54
|
+
--dest-lat 49.2965 --dest-lon -123.0884 --buffer 100
|
|
55
|
+
|
|
56
|
+
# Cameras in a bounding box
|
|
57
|
+
vancam bounds --min-lat 49.2 --min-lon -123.2 --max-lat 49.3 --max-lon -123.0
|
|
58
|
+
|
|
59
|
+
# Single camera by ID
|
|
60
|
+
vancam get 30145
|
|
61
|
+
|
|
62
|
+
# Live image
|
|
63
|
+
vancam image 30145 # prints the image URL
|
|
64
|
+
vancam image 30145 -o camera.jpg # downloads it
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
All commands print JSON to stdout (`--compact` for single-line output),
|
|
68
|
+
so this composes with `jq`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
vancam nearest --lat 49.2827 --lon -123.1207 --limit 1 | jq '.cameras[0].image_url'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Options
|
|
75
|
+
|
|
76
|
+
- `--api-key` — override `VANCAM_API_KEY`
|
|
77
|
+
- `--compact` — compact JSON instead of pretty-printed
|
|
78
|
+
|
|
79
|
+
## Use Cases
|
|
80
|
+
|
|
81
|
+
- Verify road and traffic conditions
|
|
82
|
+
- Monitor infrastructure and public spaces
|
|
83
|
+
- Check border crossings and transportation hubs
|
|
84
|
+
- Build AI agents with real-world awareness
|
|
85
|
+
- Power MCP servers, automation, and LLM applications
|
|
86
|
+
|
|
87
|
+
## Learn More
|
|
88
|
+
|
|
89
|
+
- **Website:** https://vancam.ai
|
|
90
|
+
- **API:** https://vancam.ai/api
|
|
91
|
+
- **MCP Server:** https://github.com/shughestr/mcp-vancam
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vancam"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CLI for the Vancam camera spatial search API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = {file = "LICENSE"}
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "DD"},
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"requests>=2.31.0",
|
|
26
|
+
"python-dotenv>=1.0.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://vancam.ai"
|
|
31
|
+
Repository = "https://github.com/shughestr/vancam-cli"
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
test = [
|
|
35
|
+
"pytest>=8.0.0",
|
|
36
|
+
"responses>=0.25.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
vancam = "vancam_cli.cli:main"
|
|
41
|
+
|
|
42
|
+
[build-system]
|
|
43
|
+
requires = ["setuptools>=68"]
|
|
44
|
+
build-backend = "setuptools.build_meta"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.packages.find]
|
|
47
|
+
include = ["vancam_cli*"]
|
vancam-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import responses
|
|
4
|
+
|
|
5
|
+
from vancam_cli.cli import main
|
|
6
|
+
from vancam_cli.client import DEFAULT_BASE_URL
|
|
7
|
+
|
|
8
|
+
CAMERAS_URL = f"{DEFAULT_BASE_URL}/cameras/cameras"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@responses.activate
|
|
12
|
+
def test_nearest_prints_pretty_json(capsys):
|
|
13
|
+
responses.add(responses.GET, CAMERAS_URL, json={"cameras": [1, 2]}, status=200)
|
|
14
|
+
|
|
15
|
+
rc = main(["--api-key", "k", "nearest", "--lat", "1.0", "--lon", "2.0"])
|
|
16
|
+
|
|
17
|
+
assert rc == 0
|
|
18
|
+
out = capsys.readouterr().out
|
|
19
|
+
assert json.loads(out) == {"cameras": [1, 2]}
|
|
20
|
+
assert "\n" in out.strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@responses.activate
|
|
24
|
+
def test_compact_flag_produces_single_line(capsys):
|
|
25
|
+
responses.add(responses.GET, CAMERAS_URL, json={"cameras": [1, 2]}, status=200)
|
|
26
|
+
|
|
27
|
+
rc = main(["--api-key", "k", "--compact", "nearest", "--lat", "1.0", "--lon", "2.0"])
|
|
28
|
+
|
|
29
|
+
assert rc == 0
|
|
30
|
+
out = capsys.readouterr().out
|
|
31
|
+
assert out.strip() == '{"cameras":[1,2]}'
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@responses.activate
|
|
35
|
+
def test_compact_flag_works_after_subcommand(capsys):
|
|
36
|
+
responses.add(responses.GET, CAMERAS_URL, json={"cameras": []}, status=200)
|
|
37
|
+
|
|
38
|
+
rc = main(["nearest", "--api-key", "k", "--lat", "1.0", "--lon", "2.0", "--compact"])
|
|
39
|
+
|
|
40
|
+
assert rc == 0
|
|
41
|
+
out = capsys.readouterr().out
|
|
42
|
+
assert out.strip() == '{"cameras":[]}'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@responses.activate
|
|
46
|
+
def test_get_command(capsys):
|
|
47
|
+
responses.add(responses.GET, CAMERAS_URL, json={"asset_id": "30145"}, status=200)
|
|
48
|
+
|
|
49
|
+
rc = main(["--api-key", "k", "get", "30145"])
|
|
50
|
+
|
|
51
|
+
assert rc == 0
|
|
52
|
+
assert json.loads(capsys.readouterr().out) == {"asset_id": "30145"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@responses.activate
|
|
56
|
+
def test_image_command_prints_url(capsys):
|
|
57
|
+
rc = main(["--api-key", "k", "image", "30145"])
|
|
58
|
+
|
|
59
|
+
assert rc == 0
|
|
60
|
+
out = json.loads(capsys.readouterr().out)
|
|
61
|
+
assert out == {"image_url": f"{DEFAULT_BASE_URL}/api?asset_id=30145"}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@responses.activate
|
|
65
|
+
def test_image_command_saves_to_file(tmp_path, capsys):
|
|
66
|
+
responses.add(
|
|
67
|
+
responses.GET,
|
|
68
|
+
f"{DEFAULT_BASE_URL}/api",
|
|
69
|
+
body=b"fake-image-bytes",
|
|
70
|
+
status=200,
|
|
71
|
+
)
|
|
72
|
+
dest = tmp_path / "camera.jpg"
|
|
73
|
+
|
|
74
|
+
rc = main(["--api-key", "k", "image", "30145", "-o", str(dest)])
|
|
75
|
+
|
|
76
|
+
assert rc == 0
|
|
77
|
+
assert dest.read_bytes() == b"fake-image-bytes"
|
|
78
|
+
assert "Saved to" in capsys.readouterr().err
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@responses.activate
|
|
82
|
+
def test_api_error_prints_to_stderr_and_returns_1(capsys):
|
|
83
|
+
responses.add(responses.GET, CAMERAS_URL, status=403)
|
|
84
|
+
|
|
85
|
+
rc = main(["--api-key", "k", "nearest", "--lat", "1.0", "--lon", "2.0"])
|
|
86
|
+
|
|
87
|
+
assert rc == 1
|
|
88
|
+
err = capsys.readouterr().err
|
|
89
|
+
assert "403" in err
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_missing_required_args_exits_nonzero():
|
|
93
|
+
try:
|
|
94
|
+
main(["nearest", "--lat", "1.0"])
|
|
95
|
+
assert False, "expected SystemExit for missing required --lon"
|
|
96
|
+
except SystemExit as e:
|
|
97
|
+
assert e.code != 0
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import responses
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from vancam_cli.client import (
|
|
6
|
+
ANONYMOUS_SHARED_API_KEY,
|
|
7
|
+
DEFAULT_BASE_URL,
|
|
8
|
+
MAX_LIMIT,
|
|
9
|
+
VancamAPIError,
|
|
10
|
+
VancamClient,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_defaults_to_anonymous_shared_key(monkeypatch):
|
|
15
|
+
monkeypatch.delenv("VANCAM_API_KEY", raising=False)
|
|
16
|
+
client = VancamClient()
|
|
17
|
+
assert client.api_key == ANONYMOUS_SHARED_API_KEY
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_env_var_overrides_anonymous_key(monkeypatch):
|
|
21
|
+
monkeypatch.setenv("VANCAM_API_KEY", "env-key")
|
|
22
|
+
client = VancamClient()
|
|
23
|
+
assert client.api_key == "env-key"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_explicit_api_key_wins_over_env(monkeypatch):
|
|
27
|
+
monkeypatch.setenv("VANCAM_API_KEY", "env-key")
|
|
28
|
+
client = VancamClient(api_key="explicit-key")
|
|
29
|
+
assert client.api_key == "explicit-key"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_headers_include_api_key():
|
|
33
|
+
client = VancamClient(api_key="abc123")
|
|
34
|
+
assert client._headers() == {"x-api-key": "abc123"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_headers_empty_without_api_key():
|
|
38
|
+
# api_key="" falls through the `or` fallback in __init__ to the shared
|
|
39
|
+
# key, so the only way to reach a truly empty key is to set it directly.
|
|
40
|
+
client = VancamClient(api_key="k")
|
|
41
|
+
client.api_key = ""
|
|
42
|
+
assert client._headers() == {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@responses.activate
|
|
46
|
+
def test_radius_hits_expected_endpoint_and_params():
|
|
47
|
+
responses.add(
|
|
48
|
+
responses.GET,
|
|
49
|
+
f"{DEFAULT_BASE_URL}/cameras/cameras",
|
|
50
|
+
json={"cameras": []},
|
|
51
|
+
status=200,
|
|
52
|
+
)
|
|
53
|
+
client = VancamClient(api_key="k")
|
|
54
|
+
result = client.radius(lat=49.28, lon=-123.12, radius=1.0, limit=10)
|
|
55
|
+
|
|
56
|
+
assert result == {"cameras": []}
|
|
57
|
+
request = responses.calls[0].request
|
|
58
|
+
assert request.headers["x-api-key"] == "k"
|
|
59
|
+
assert "lat=49.28" in request.url
|
|
60
|
+
assert "radius=1.0" in request.url
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@responses.activate
|
|
64
|
+
def test_nearest_sets_nearest_flag():
|
|
65
|
+
responses.add(
|
|
66
|
+
responses.GET,
|
|
67
|
+
f"{DEFAULT_BASE_URL}/cameras/cameras",
|
|
68
|
+
json={"cameras": []},
|
|
69
|
+
status=200,
|
|
70
|
+
)
|
|
71
|
+
client = VancamClient(api_key="k")
|
|
72
|
+
client.nearest(lat=1.0, lon=2.0, limit=5)
|
|
73
|
+
|
|
74
|
+
request = responses.calls[0].request
|
|
75
|
+
assert "nearest=true" in request.url
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@responses.activate
|
|
79
|
+
def test_limit_is_clamped_to_max():
|
|
80
|
+
responses.add(
|
|
81
|
+
responses.GET,
|
|
82
|
+
f"{DEFAULT_BASE_URL}/cameras/cameras",
|
|
83
|
+
json={"cameras": []},
|
|
84
|
+
status=200,
|
|
85
|
+
)
|
|
86
|
+
client = VancamClient(api_key="k")
|
|
87
|
+
client.radius(lat=1.0, lon=2.0, radius=1.0, limit=10_000)
|
|
88
|
+
|
|
89
|
+
request = responses.calls[0].request
|
|
90
|
+
assert f"limit={MAX_LIMIT}" in request.url
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@responses.activate
|
|
94
|
+
def test_get_camera_uses_asset_id_param():
|
|
95
|
+
responses.add(
|
|
96
|
+
responses.GET,
|
|
97
|
+
f"{DEFAULT_BASE_URL}/cameras/cameras",
|
|
98
|
+
json={"asset_id": "30145"},
|
|
99
|
+
status=200,
|
|
100
|
+
)
|
|
101
|
+
client = VancamClient(api_key="k")
|
|
102
|
+
result = client.get_camera("30145")
|
|
103
|
+
|
|
104
|
+
assert result == {"asset_id": "30145"}
|
|
105
|
+
assert "asset_id=30145" in responses.calls[0].request.url
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_no_api_key_raises_before_request():
|
|
109
|
+
client = VancamClient(api_key="k")
|
|
110
|
+
client.api_key = ""
|
|
111
|
+
with pytest.raises(VancamAPIError, match="No API key set"):
|
|
112
|
+
client.radius(lat=1.0, lon=2.0, radius=1.0)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@responses.activate
|
|
116
|
+
def test_403_raises_with_status_code():
|
|
117
|
+
responses.add(
|
|
118
|
+
responses.GET,
|
|
119
|
+
f"{DEFAULT_BASE_URL}/cameras/cameras",
|
|
120
|
+
status=403,
|
|
121
|
+
)
|
|
122
|
+
client = VancamClient(api_key="k")
|
|
123
|
+
with pytest.raises(VancamAPIError) as exc_info:
|
|
124
|
+
client.radius(lat=1.0, lon=2.0, radius=1.0)
|
|
125
|
+
assert exc_info.value.status_code == 403
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@responses.activate
|
|
129
|
+
def test_429_raises_with_status_code():
|
|
130
|
+
responses.add(
|
|
131
|
+
responses.GET,
|
|
132
|
+
f"{DEFAULT_BASE_URL}/cameras/cameras",
|
|
133
|
+
status=429,
|
|
134
|
+
)
|
|
135
|
+
client = VancamClient(api_key="k")
|
|
136
|
+
with pytest.raises(VancamAPIError) as exc_info:
|
|
137
|
+
client.radius(lat=1.0, lon=2.0, radius=1.0)
|
|
138
|
+
assert exc_info.value.status_code == 429
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@responses.activate
|
|
142
|
+
def test_request_exception_wrapped_as_api_error():
|
|
143
|
+
responses.add(
|
|
144
|
+
responses.GET,
|
|
145
|
+
f"{DEFAULT_BASE_URL}/cameras/cameras",
|
|
146
|
+
body=requests.exceptions.ConnectionError("boom"),
|
|
147
|
+
)
|
|
148
|
+
client = VancamClient(api_key="k")
|
|
149
|
+
with pytest.raises(VancamAPIError, match="Request failed"):
|
|
150
|
+
client.radius(lat=1.0, lon=2.0, radius=1.0)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_image_url_format():
|
|
154
|
+
client = VancamClient(api_key="k", base_url="https://api.vancam.ai")
|
|
155
|
+
assert client.image_url("30145") == "https://api.vancam.ai/api?asset_id=30145"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@responses.activate
|
|
159
|
+
def test_fetch_image_returns_bytes():
|
|
160
|
+
responses.add(
|
|
161
|
+
responses.GET,
|
|
162
|
+
f"{DEFAULT_BASE_URL}/api",
|
|
163
|
+
body=b"binary-image-data",
|
|
164
|
+
status=200,
|
|
165
|
+
)
|
|
166
|
+
client = VancamClient(api_key="k")
|
|
167
|
+
assert client.fetch_image("30145") == b"binary-image-data"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_fetch_image_no_api_key_raises():
|
|
171
|
+
client = VancamClient(api_key="k")
|
|
172
|
+
client.api_key = ""
|
|
173
|
+
with pytest.raises(VancamAPIError, match="No API key set"):
|
|
174
|
+
client.fetch_image("30145")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vancam
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for the Vancam camera spatial search API
|
|
5
|
+
Author: DD
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 DD
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://vancam.ai
|
|
29
|
+
Project-URL: Repository, https://github.com/shughestr/vancam-cli
|
|
30
|
+
Classifier: Environment :: Console
|
|
31
|
+
Classifier: Intended Audience :: Developers
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: Python :: 3
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
40
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
41
|
+
Requires-Python: >=3.9
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: requests>=2.31.0
|
|
45
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
46
|
+
Provides-Extra: test
|
|
47
|
+
Requires-Dist: pytest>=8.0.0; extra == "test"
|
|
48
|
+
Requires-Dist: responses>=0.25.0; extra == "test"
|
|
49
|
+
Dynamic: license-file
|
|
50
|
+
|
|
51
|
+
# Vancam CLI
|
|
52
|
+
|
|
53
|
+
**Real-world observation for AI agents.**
|
|
54
|
+
|
|
55
|
+
Command-line client for the Vancam camera spatial search API
|
|
56
|
+
(`api.vancam.ai`), giving developers and AI agents instant access to
|
|
57
|
+
**1M+ live public cameras across 100+ countries** — the same backend used
|
|
58
|
+
by the [MCP server](https://github.com/shughestr/mcp-vancam) and the
|
|
59
|
+
[vancam.ai](https://vancam.ai) web app.
|
|
60
|
+
|
|
61
|
+
Instead of relying on outdated web pages or user reports, Vancam provides
|
|
62
|
+
fresh visual evidence from the physical world, letting AI systems answer
|
|
63
|
+
location-based questions with confidence.
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
vancam REST API
|
|
67
|
+
├── vancam-mcp (MCP server)
|
|
68
|
+
├── vancam-cli (this project)
|
|
69
|
+
├── SDKs (future)
|
|
70
|
+
└── vancam.ai (web app)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install vancam
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Setup
|
|
80
|
+
|
|
81
|
+
No setup required to try it — defaults to a shared, public key if
|
|
82
|
+
`VANCAM_API_KEY` isn't set. That's **one key used by every anonymous
|
|
83
|
+
caller**, so its 1 req/s / 500 requests/month limit is a collective pool
|
|
84
|
+
shared across all of them, not 500 each. For a limit that's yours alone
|
|
85
|
+
(5 req/s, 500 requests/month, not shared), get a free personal key from your
|
|
86
|
+
account dashboard at [vancam.ai/account](https://vancam.ai/account):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
cp .env.example .env
|
|
90
|
+
# edit .env, or export VANCAM_API_KEY directly
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Usage
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# N nearest cameras to a point
|
|
97
|
+
vancam nearest --lat 49.2827 --lon -123.1207 --limit 5
|
|
98
|
+
|
|
99
|
+
# Cameras within a radius (km)
|
|
100
|
+
vancam radius --lat 49.2827 --lon -123.1207 --radius 1
|
|
101
|
+
|
|
102
|
+
# Cameras along a route
|
|
103
|
+
vancam route --origin-lat 49.2827 --origin-lon -123.1207 \
|
|
104
|
+
--dest-lat 49.2965 --dest-lon -123.0884 --buffer 100
|
|
105
|
+
|
|
106
|
+
# Cameras in a bounding box
|
|
107
|
+
vancam bounds --min-lat 49.2 --min-lon -123.2 --max-lat 49.3 --max-lon -123.0
|
|
108
|
+
|
|
109
|
+
# Single camera by ID
|
|
110
|
+
vancam get 30145
|
|
111
|
+
|
|
112
|
+
# Live image
|
|
113
|
+
vancam image 30145 # prints the image URL
|
|
114
|
+
vancam image 30145 -o camera.jpg # downloads it
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
All commands print JSON to stdout (`--compact` for single-line output),
|
|
118
|
+
so this composes with `jq`:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
vancam nearest --lat 49.2827 --lon -123.1207 --limit 1 | jq '.cameras[0].image_url'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Options
|
|
125
|
+
|
|
126
|
+
- `--api-key` — override `VANCAM_API_KEY`
|
|
127
|
+
- `--compact` — compact JSON instead of pretty-printed
|
|
128
|
+
|
|
129
|
+
## Use Cases
|
|
130
|
+
|
|
131
|
+
- Verify road and traffic conditions
|
|
132
|
+
- Monitor infrastructure and public spaces
|
|
133
|
+
- Check border crossings and transportation hubs
|
|
134
|
+
- Build AI agents with real-world awareness
|
|
135
|
+
- Power MCP servers, automation, and LLM applications
|
|
136
|
+
|
|
137
|
+
## Learn More
|
|
138
|
+
|
|
139
|
+
- **Website:** https://vancam.ai
|
|
140
|
+
- **API:** https://vancam.ai/api
|
|
141
|
+
- **MCP Server:** https://github.com/shughestr/mcp-vancam
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_cli.py
|
|
5
|
+
tests/test_client.py
|
|
6
|
+
vancam.egg-info/PKG-INFO
|
|
7
|
+
vancam.egg-info/SOURCES.txt
|
|
8
|
+
vancam.egg-info/dependency_links.txt
|
|
9
|
+
vancam.egg-info/entry_points.txt
|
|
10
|
+
vancam.egg-info/requires.txt
|
|
11
|
+
vancam.egg-info/top_level.txt
|
|
12
|
+
vancam_cli/__init__.py
|
|
13
|
+
vancam_cli/cli.py
|
|
14
|
+
vancam_cli/client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vancam_cli
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""vancam — CLI for the Vancam camera spatial search API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from dotenv import find_dotenv, load_dotenv
|
|
11
|
+
|
|
12
|
+
from .client import VancamAPIError, VancamClient, DEFAULT_BASE_URL
|
|
13
|
+
|
|
14
|
+
# usecwd=True: search from the user's current directory, not the installed
|
|
15
|
+
# package's location (python-dotenv's default walks up from __file__, which
|
|
16
|
+
# would silently pick up this repo's own .env regardless of where the CLI
|
|
17
|
+
# is actually run from).
|
|
18
|
+
load_dotenv(find_dotenv(usecwd=True))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _print_json(data, compact: bool) -> None:
|
|
22
|
+
if compact:
|
|
23
|
+
print(json.dumps(data, separators=(",", ":")))
|
|
24
|
+
else:
|
|
25
|
+
print(json.dumps(data, indent=2))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _client_from_args(args: argparse.Namespace) -> VancamClient:
|
|
29
|
+
return VancamClient(api_key=args.api_key, base_url=args.base_url)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_radius(args: argparse.Namespace) -> Any:
|
|
33
|
+
return _client_from_args(args).radius(args.lat, args.lon, args.radius, args.limit)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_nearest(args: argparse.Namespace):
|
|
37
|
+
return _client_from_args(args).nearest(args.lat, args.lon, args.limit)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_route(args: argparse.Namespace):
|
|
41
|
+
return _client_from_args(args).route(
|
|
42
|
+
args.origin_lat, args.origin_lon, args.dest_lat, args.dest_lon, args.buffer, args.limit
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cmd_bounds(args: argparse.Namespace):
|
|
47
|
+
return _client_from_args(args).bounds(
|
|
48
|
+
args.min_lat, args.min_lon, args.max_lat, args.max_lon, args.limit
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_get(args: argparse.Namespace):
|
|
53
|
+
return _client_from_args(args).get_camera(args.asset_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_image(args: argparse.Namespace):
|
|
57
|
+
client = _client_from_args(args)
|
|
58
|
+
if args.output:
|
|
59
|
+
data = client.fetch_image(args.asset_id)
|
|
60
|
+
with open(args.output, "wb") as f:
|
|
61
|
+
f.write(data)
|
|
62
|
+
print(f"Saved to {args.output}", file=sys.stderr)
|
|
63
|
+
return None
|
|
64
|
+
return {"image_url": client.image_url(args.asset_id)}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
68
|
+
# Shared as a `parents=[...]` mixin so these flags work both before AND
|
|
69
|
+
# after the subcommand (argparse doesn't inherit parent-only args to
|
|
70
|
+
# subparsers otherwise).
|
|
71
|
+
#
|
|
72
|
+
# Two copies are needed: the top-level parser needs real defaults so
|
|
73
|
+
# `args.compact` etc. always exist, but if the *subparser* copy also
|
|
74
|
+
# carried those defaults, it would silently reset any value already set
|
|
75
|
+
# by the top-level parser back to the default when the flag isn't
|
|
76
|
+
# repeated after the subcommand (argparse parses both parsers into the
|
|
77
|
+
# same Namespace). default=SUPPRESS on the subparser copy makes it only
|
|
78
|
+
# write when the flag is actually given after the subcommand.
|
|
79
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
80
|
+
common.add_argument("--api-key", help="API key (default: VANCAM_API_KEY env var)")
|
|
81
|
+
common.add_argument("--base-url", default=DEFAULT_BASE_URL, help=argparse.SUPPRESS)
|
|
82
|
+
common.add_argument("--compact", action="store_true", help="Compact JSON output")
|
|
83
|
+
|
|
84
|
+
common_sub = argparse.ArgumentParser(add_help=False)
|
|
85
|
+
common_sub.add_argument("--api-key", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
86
|
+
common_sub.add_argument("--base-url", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
87
|
+
common_sub.add_argument(
|
|
88
|
+
"--compact", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
parser = argparse.ArgumentParser(
|
|
92
|
+
prog="vancam",
|
|
93
|
+
description="Query the Vancam camera spatial search API (api.vancam.ai).",
|
|
94
|
+
parents=[common],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
98
|
+
|
|
99
|
+
p = sub.add_parser("radius", help="Cameras within a radius (km) of a point", parents=[common_sub])
|
|
100
|
+
p.add_argument("--lat", type=float, required=True)
|
|
101
|
+
p.add_argument("--lon", type=float, required=True)
|
|
102
|
+
p.add_argument("--radius", type=float, default=1.0, help="km, max 50 (default: 1.0)")
|
|
103
|
+
p.add_argument("--limit", type=int, default=50)
|
|
104
|
+
p.set_defaults(func=cmd_radius)
|
|
105
|
+
|
|
106
|
+
p = sub.add_parser("nearest", help="N nearest cameras to a point", parents=[common_sub])
|
|
107
|
+
p.add_argument("--lat", type=float, required=True)
|
|
108
|
+
p.add_argument("--lon", type=float, required=True)
|
|
109
|
+
p.add_argument("--limit", type=int, default=5)
|
|
110
|
+
p.set_defaults(func=cmd_nearest)
|
|
111
|
+
|
|
112
|
+
p = sub.add_parser("route", help="Cameras along a straight-line route", parents=[common_sub])
|
|
113
|
+
p.add_argument("--origin-lat", type=float, required=True)
|
|
114
|
+
p.add_argument("--origin-lon", type=float, required=True)
|
|
115
|
+
p.add_argument("--dest-lat", type=float, required=True)
|
|
116
|
+
p.add_argument("--dest-lon", type=float, required=True)
|
|
117
|
+
p.add_argument("--buffer", type=float, default=100.0, help="corridor width in meters")
|
|
118
|
+
p.add_argument("--limit", type=int, default=50)
|
|
119
|
+
p.set_defaults(func=cmd_route)
|
|
120
|
+
|
|
121
|
+
p = sub.add_parser("bounds", help="Cameras inside a bounding box", parents=[common_sub])
|
|
122
|
+
p.add_argument("--min-lat", type=float, required=True)
|
|
123
|
+
p.add_argument("--min-lon", type=float, required=True)
|
|
124
|
+
p.add_argument("--max-lat", type=float, required=True)
|
|
125
|
+
p.add_argument("--max-lon", type=float, required=True)
|
|
126
|
+
p.add_argument("--limit", type=int, default=100)
|
|
127
|
+
p.set_defaults(func=cmd_bounds)
|
|
128
|
+
|
|
129
|
+
p = sub.add_parser("get", help="Camera details by asset ID", parents=[common_sub])
|
|
130
|
+
p.add_argument("asset_id")
|
|
131
|
+
p.set_defaults(func=cmd_get)
|
|
132
|
+
|
|
133
|
+
p = sub.add_parser("image", help="Get (or save) a camera's live image", parents=[common_sub])
|
|
134
|
+
p.add_argument("asset_id")
|
|
135
|
+
p.add_argument("-o", "--output", help="Save image to this file instead of printing the URL")
|
|
136
|
+
p.set_defaults(func=cmd_image)
|
|
137
|
+
|
|
138
|
+
return parser
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv: Optional[list] = None) -> int:
|
|
142
|
+
parser = build_parser()
|
|
143
|
+
args = parser.parse_args(argv)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
result = args.func(args)
|
|
147
|
+
except VancamAPIError as e:
|
|
148
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
|
|
151
|
+
if result is not None:
|
|
152
|
+
_print_json(result, args.compact)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
sys.exit(main())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Thin HTTP client for the Vancam REST API (api.vancam.ai)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
DEFAULT_BASE_URL = "https://api.vancam.ai"
|
|
11
|
+
DEFAULT_TIMEOUT_SECONDS = 15
|
|
12
|
+
MAX_LIMIT = 100
|
|
13
|
+
|
|
14
|
+
# Shared, low-limit key (1 req/s, 500/month) — meant to be public, used when
|
|
15
|
+
# the caller hasn't set a personal key. Get a personal free-tier key (higher
|
|
16
|
+
# limits, 5 req/s, 500/month) from the web app's /account page.
|
|
17
|
+
ANONYMOUS_SHARED_API_KEY = "abwSAo91eI8wENsNrZFUE7tC6qGkh9Tw66x4cfcQ"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VancamAPIError(RuntimeError):
|
|
21
|
+
def __init__(self, message: str, status_code: Optional[int] = None):
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.status_code = status_code
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VancamClient:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
api_key: Optional[str] = None,
|
|
30
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
31
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
32
|
+
):
|
|
33
|
+
self.api_key = api_key or os.getenv("VANCAM_API_KEY", ANONYMOUS_SHARED_API_KEY)
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self.timeout = timeout
|
|
36
|
+
|
|
37
|
+
def _headers(self) -> Dict[str, str]:
|
|
38
|
+
return {"x-api-key": self.api_key} if self.api_key else {}
|
|
39
|
+
|
|
40
|
+
def _get(self, path: str, params: Dict[str, Any]) -> Any:
|
|
41
|
+
if not self.api_key:
|
|
42
|
+
raise VancamAPIError(
|
|
43
|
+
"No API key set. Set VANCAM_API_KEY in your environment or pass --api-key.\n"
|
|
44
|
+
"Get a free key from your account dashboard at https://vancam.ai/account."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
clean = {k: v for k, v in params.items() if v is not None}
|
|
48
|
+
if "limit" in clean:
|
|
49
|
+
clean["limit"] = max(1, min(int(clean["limit"]), MAX_LIMIT))
|
|
50
|
+
|
|
51
|
+
url = f"{self.base_url}{path}"
|
|
52
|
+
try:
|
|
53
|
+
response = requests.get(
|
|
54
|
+
url, params=clean, headers=self._headers(), timeout=self.timeout
|
|
55
|
+
)
|
|
56
|
+
except requests.exceptions.RequestException as e:
|
|
57
|
+
raise VancamAPIError(f"Request failed: {e}") from e
|
|
58
|
+
|
|
59
|
+
if response.status_code == 403:
|
|
60
|
+
raise VancamAPIError(
|
|
61
|
+
"403 Forbidden — missing or invalid API key.", status_code=403
|
|
62
|
+
)
|
|
63
|
+
if response.status_code == 429:
|
|
64
|
+
raise VancamAPIError(
|
|
65
|
+
"429 Too Many Requests — rate limit exceeded for your plan.",
|
|
66
|
+
status_code=429,
|
|
67
|
+
)
|
|
68
|
+
try:
|
|
69
|
+
response.raise_for_status()
|
|
70
|
+
except requests.exceptions.HTTPError as e:
|
|
71
|
+
raise VancamAPIError(
|
|
72
|
+
f"HTTP error {response.status_code}: {response.text}",
|
|
73
|
+
status_code=response.status_code,
|
|
74
|
+
) from e
|
|
75
|
+
|
|
76
|
+
return response.json()
|
|
77
|
+
|
|
78
|
+
def radius(self, lat: float, lon: float, radius: float, limit: Optional[int] = None) -> Any:
|
|
79
|
+
return self._get(
|
|
80
|
+
"/cameras/cameras",
|
|
81
|
+
{"lat": lat, "lon": lon, "radius": radius, "limit": limit},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def nearest(self, lat: float, lon: float, limit: Optional[int] = None) -> Any:
|
|
85
|
+
return self._get(
|
|
86
|
+
"/cameras/cameras",
|
|
87
|
+
{"lat": lat, "lon": lon, "nearest": "true", "limit": limit},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def route(
|
|
91
|
+
self,
|
|
92
|
+
origin_lat: float,
|
|
93
|
+
origin_lon: float,
|
|
94
|
+
dest_lat: float,
|
|
95
|
+
dest_lon: float,
|
|
96
|
+
buffer: Optional[float] = None,
|
|
97
|
+
limit: Optional[int] = None,
|
|
98
|
+
) -> Any:
|
|
99
|
+
return self._get(
|
|
100
|
+
"/cameras/cameras",
|
|
101
|
+
{
|
|
102
|
+
"origin_lat": origin_lat,
|
|
103
|
+
"origin_lon": origin_lon,
|
|
104
|
+
"dest_lat": dest_lat,
|
|
105
|
+
"dest_lon": dest_lon,
|
|
106
|
+
"buffer": buffer,
|
|
107
|
+
"limit": limit,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def bounds(
|
|
112
|
+
self,
|
|
113
|
+
min_lat: float,
|
|
114
|
+
min_lon: float,
|
|
115
|
+
max_lat: float,
|
|
116
|
+
max_lon: float,
|
|
117
|
+
limit: Optional[int] = None,
|
|
118
|
+
) -> Any:
|
|
119
|
+
return self._get(
|
|
120
|
+
"/cameras/cameras",
|
|
121
|
+
{
|
|
122
|
+
"min_lat": min_lat,
|
|
123
|
+
"min_lon": min_lon,
|
|
124
|
+
"max_lat": max_lat,
|
|
125
|
+
"max_lon": max_lon,
|
|
126
|
+
"limit": limit,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def get_camera(self, asset_id: str) -> Any:
|
|
131
|
+
return self._get("/cameras/cameras", {"asset_id": asset_id})
|
|
132
|
+
|
|
133
|
+
def image_url(self, asset_id: str) -> str:
|
|
134
|
+
return f"{self.base_url}/api?asset_id={asset_id}"
|
|
135
|
+
|
|
136
|
+
def fetch_image(self, asset_id: str) -> bytes:
|
|
137
|
+
if not self.api_key:
|
|
138
|
+
raise VancamAPIError(
|
|
139
|
+
"No API key set. Set VANCAM_API_KEY in your environment or pass --api-key."
|
|
140
|
+
)
|
|
141
|
+
url = self.image_url(asset_id)
|
|
142
|
+
try:
|
|
143
|
+
response = requests.get(url, headers=self._headers(), timeout=self.timeout)
|
|
144
|
+
response.raise_for_status()
|
|
145
|
+
except requests.exceptions.RequestException as e:
|
|
146
|
+
raise VancamAPIError(f"Image fetch failed: {e}") from e
|
|
147
|
+
return response.content
|