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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ vancam = vancam_cli.cli:main
@@ -0,0 +1,6 @@
1
+ requests>=2.31.0
2
+ python-dotenv>=1.0.0
3
+
4
+ [test]
5
+ pytest>=8.0.0
6
+ responses>=0.25.0
@@ -0,0 +1 @@
1
+ vancam_cli
@@ -0,0 +1,3 @@
1
+ """Vancam CLI — thin client over the api.vancam.ai REST API."""
2
+
3
+ __version__ = "0.1.0"
@@ -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