inmet-forecast 1.0.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.
- inmet_forecast-1.0.0/LICENSE +21 -0
- inmet_forecast-1.0.0/PKG-INFO +184 -0
- inmet_forecast-1.0.0/README.md +168 -0
- inmet_forecast-1.0.0/pyproject.toml +36 -0
- inmet_forecast-1.0.0/setup.cfg +4 -0
- inmet_forecast-1.0.0/src/forecast/__init__.py +16 -0
- inmet_forecast-1.0.0/src/forecast/__main__.py +3 -0
- inmet_forecast-1.0.0/src/forecast/cli.py +40 -0
- inmet_forecast-1.0.0/src/forecast/client.py +80 -0
- inmet_forecast-1.0.0/src/forecast/errors.py +21 -0
- inmet_forecast-1.0.0/src/forecast/forecast.py +108 -0
- inmet_forecast-1.0.0/src/forecast/py.typed +0 -0
- inmet_forecast-1.0.0/src/inmet_forecast.egg-info/PKG-INFO +184 -0
- inmet_forecast-1.0.0/src/inmet_forecast.egg-info/SOURCES.txt +16 -0
- inmet_forecast-1.0.0/src/inmet_forecast.egg-info/dependency_links.txt +1 -0
- inmet_forecast-1.0.0/src/inmet_forecast.egg-info/entry_points.txt +2 -0
- inmet_forecast-1.0.0/src/inmet_forecast.egg-info/top_level.txt +1 -0
- inmet_forecast-1.0.0/tests/test_forecast.py +231 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 inmet-forecast contributors
|
|
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,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inmet-forecast
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A dependency-free Python client for INMET municipality forecasts
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: inmet,weather,forecast,brazil
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# inmet-forecast
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="docs/inmet-forecast-icon.png" width="128" alt="inmet-forecast weather icon: sun, cloud, and rain">
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
A dependency-free Python client for INMET's Brazilian municipality forecasts,
|
|
25
|
+
with raw API data, normalized records, and a JSON command line.
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<a href="https://github.com/rteoo/inmet-forecast/actions/workflows/publish.yml"><img src="https://github.com/rteoo/inmet-forecast/actions/workflows/publish.yml/badge.svg" alt="Publishing workflow status"></a>
|
|
30
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10 or later">
|
|
31
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
Fetch forecasts by IBGE municipality code from Python or the command line.
|
|
35
|
+
Keep INMET's original JSON or turn its period-based and daily entries into
|
|
36
|
+
chronological records without losing Portuguese descriptions or unknown fields.
|
|
37
|
+
|
|
38
|
+
## Highlights
|
|
39
|
+
|
|
40
|
+
- Municipality forecasts from INMET's forecast API, including temperature,
|
|
41
|
+
humidity, wind, weather descriptions, sunrise, and sunset when supplied.
|
|
42
|
+
- Raw responses and normalized morning, afternoon, night, and daily records.
|
|
43
|
+
- UTF-8 JSON output through `inmet-forecast` or `python -m forecast`.
|
|
44
|
+
- Configurable socket timeouts, bounded responses, and specific error classes.
|
|
45
|
+
- Python 3.10 or later, using only the standard library at runtime.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
Install from a repository checkout:
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
git clone https://github.com/rteoo/inmet-forecast.git
|
|
53
|
+
cd inmet-forecast
|
|
54
|
+
python -m venv .venv
|
|
55
|
+
.venv\Scripts\Activate.ps1
|
|
56
|
+
python -m pip install .
|
|
57
|
+
python -m forecast 5218508
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Use `python -m pip install -e .` for an editable development install. The
|
|
61
|
+
distribution and console command are named `inmet-forecast`; the Python import
|
|
62
|
+
is `forecast`. The example uses Quirinópolis, Goiás, municipality code `5218508`.
|
|
63
|
+
|
|
64
|
+
## Python
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from forecast import InmetClient, fetch_forecast, normalize_forecast
|
|
68
|
+
|
|
69
|
+
# Quirinópolis, Goiás (IBGE municipality code).
|
|
70
|
+
raw = fetch_forecast(5218508, timeout=20)
|
|
71
|
+
records = normalize_forecast(raw)
|
|
72
|
+
|
|
73
|
+
for record in records:
|
|
74
|
+
print(record["date"], record["period"], record["resumo"])
|
|
75
|
+
|
|
76
|
+
# Reuse a configured client across requests.
|
|
77
|
+
client = InmetClient(timeout=30)
|
|
78
|
+
raw = client.get_forecast("5218508")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`fetch_forecast()` and `get_forecast()` return the original validated dictionary,
|
|
82
|
+
including base64 icons. `normalize_forecast()` returns chronological rows with
|
|
83
|
+
`municipality_code`, ISO `date`, and `period` (`morning`, `afternoon`, `night`, or
|
|
84
|
+
`daily`). Original INMET fields and Portuguese descriptions are retained. Embedded
|
|
85
|
+
images are excluded from normalized rows unless `include_icons=True`.
|
|
86
|
+
|
|
87
|
+
The first two dates currently contain `manha`, `tarde`, and `noite` objects; later
|
|
88
|
+
dates contain one daily object. Normalization detects the shape of each date
|
|
89
|
+
instead of assuming a fixed five-day horizon. Temperatures are Celsius and
|
|
90
|
+
humidity values are percentages; numeric values may be `None` when missing.
|
|
91
|
+
Dates arrive from INMET as `DD/MM/YYYY`.
|
|
92
|
+
|
|
93
|
+
## Command line
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
inmet-forecast 5218508
|
|
97
|
+
python -m forecast 5218508 --timeout 30
|
|
98
|
+
python -m forecast 5218508 --raw
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Default output is normalized UTF-8 JSON. `--raw` includes all original fields
|
|
102
|
+
and embedded images; `--include-icons` keeps images in normalized output.
|
|
103
|
+
Failures print an error to stderr and return exit status 1.
|
|
104
|
+
|
|
105
|
+
## Errors and service limits
|
|
106
|
+
|
|
107
|
+
Catch `InmetError` for service failures, or its specific subclasses:
|
|
108
|
+
`InmetHTTPError` (with `.status`), `InmetNetworkError`, and `InmetResponseError`.
|
|
109
|
+
Invalid codes and timeouts raise `ValueError` before making a request.
|
|
110
|
+
Responses are limited to 8 MiB, and must be nonempty UTF-8 JSON with valid forecast
|
|
111
|
+
entries. The timeout bounds individual socket operations, not total elapsed time.
|
|
112
|
+
There are no automatic retries or caches. Caller applications should cache
|
|
113
|
+
appropriately and label retrieval times.
|
|
114
|
+
|
|
115
|
+
Only forecasts are supported. They are not current station measurements.
|
|
116
|
+
The API has no moon-phase field in the response inspected on 2026-10-07.
|
|
117
|
+
Do not keep today's temperature header when showing tomorrow's forecast: use
|
|
118
|
+
the fields from the selected date/period.
|
|
119
|
+
|
|
120
|
+
## Source and verification
|
|
121
|
+
|
|
122
|
+
- [Forecast API example](https://apiprevmet3.inmet.gov.br/previsao/5218508)
|
|
123
|
+
- [Official forecast page](https://previsao.inmet.gov.br/5218508)
|
|
124
|
+
- [IBGE municipality](https://www.ibge.gov.br/cidades-e-estados/go/quirinopolis.html)
|
|
125
|
+
- [INMET forecast service](https://portal.inmet.gov.br/servicos/previs%C3%A3o-do-tempo)
|
|
126
|
+
- [API access contact](https://portal.inmet.gov.br/fale-conosco): api@inmet.gov.br
|
|
127
|
+
|
|
128
|
+
The official forecast frontend uses this API. An unauthenticated request returned
|
|
129
|
+
HTTP 200 on 2026-10-07; this is a point-in-time observation, not an authentication,
|
|
130
|
+
rate-limit, uptime, or schema guarantee. This project is an independent client
|
|
131
|
+
and is not affiliated with INMET. Data remains attributed to INMET; the MIT
|
|
132
|
+
license covers this client code, not a grant of rights over third-party data.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
Tests use the standard library and a local HTTP server; they never call INMET.
|
|
137
|
+
|
|
138
|
+
```powershell
|
|
139
|
+
$env:PYTHONPATH = 'src'
|
|
140
|
+
python -W error::ResourceWarning -m unittest discover -s tests -v
|
|
141
|
+
python -m ruff check src tests
|
|
142
|
+
python -m build --no-isolation
|
|
143
|
+
python -m twine check dist/*
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The build and lint commands use tooling already installed on the host. Tests
|
|
147
|
+
shut down their server and close their files even on failure.
|
|
148
|
+
|
|
149
|
+
Verified on Windows/Python 3.14.6 on 2026-10-07: 18 tests passed from source and
|
|
150
|
+
from an installed wheel, Ruff passed, wheel/sdist builds and Twine checks passed.
|
|
151
|
+
The installed CLI fetched nine forecast rows for Quirinópolis. October 7's
|
|
152
|
+
afternoon forecast matched 19–36°C, 30–90% humidity, light NE-E winds, and the
|
|
153
|
+
portal's showers/thunderstorms description. Other Python versions and operating
|
|
154
|
+
systems have not been exercised locally.
|
|
155
|
+
|
|
156
|
+
## Publishing to PyPI
|
|
157
|
+
|
|
158
|
+
`.github/workflows/publish.yml` publishes when a GitHub release is published. It tests
|
|
159
|
+
the installed package on Python 3.10 through 3.14, checks lint and formatting,
|
|
160
|
+
builds and validates a wheel and source distribution, then uploads those same
|
|
161
|
+
artifacts using [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/).
|
|
162
|
+
No PyPI API token is needed. A manual workflow run performs validation only.
|
|
163
|
+
|
|
164
|
+
Before the first release:
|
|
165
|
+
|
|
166
|
+
1. Create the GitHub repository environment `pypi`. Configure required reviewers
|
|
167
|
+
and restrict its deployment tags to `v*` where the repository plan permits.
|
|
168
|
+
2. Register a [pending PyPI publisher](https://pypi.org/manage/account/publishing/)
|
|
169
|
+
with project name `inmet-forecast`, owner `rteoo`, repository `inmet-forecast`,
|
|
170
|
+
workflow filename `publish.yml`, and environment `pypi`.
|
|
171
|
+
3. Publish a GitHub release whose tag exactly matches `v` plus the version in
|
|
172
|
+
`pyproject.toml`, currently `v1.0.0`. The tagged commit must contain the workflow.
|
|
173
|
+
|
|
174
|
+
For later releases, update the package version before tagging. PyPI versions
|
|
175
|
+
cannot be overwritten. The workflow deliberately fails on an existing version
|
|
176
|
+
instead of silently skipping its upload. GitHub Actions execution and PyPI
|
|
177
|
+
publication have not been verified from this local checkout.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
This client is released under the [MIT License](LICENSE). Weather data remains
|
|
182
|
+
attributed to INMET. The [project icon](docs/inmet-forecast-icon.png) is an
|
|
183
|
+
independent weather mark; its design reference and generation prompt are recorded
|
|
184
|
+
in [docs/README.md](docs/README.md).
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# inmet-forecast
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="docs/inmet-forecast-icon.png" width="128" alt="inmet-forecast weather icon: sun, cloud, and rain">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
A dependency-free Python client for INMET's Brazilian municipality forecasts,
|
|
9
|
+
with raw API data, normalized records, and a JSON command line.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://github.com/rteoo/inmet-forecast/actions/workflows/publish.yml"><img src="https://github.com/rteoo/inmet-forecast/actions/workflows/publish.yml/badge.svg" alt="Publishing workflow status"></a>
|
|
14
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10 or later">
|
|
15
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
Fetch forecasts by IBGE municipality code from Python or the command line.
|
|
19
|
+
Keep INMET's original JSON or turn its period-based and daily entries into
|
|
20
|
+
chronological records without losing Portuguese descriptions or unknown fields.
|
|
21
|
+
|
|
22
|
+
## Highlights
|
|
23
|
+
|
|
24
|
+
- Municipality forecasts from INMET's forecast API, including temperature,
|
|
25
|
+
humidity, wind, weather descriptions, sunrise, and sunset when supplied.
|
|
26
|
+
- Raw responses and normalized morning, afternoon, night, and daily records.
|
|
27
|
+
- UTF-8 JSON output through `inmet-forecast` or `python -m forecast`.
|
|
28
|
+
- Configurable socket timeouts, bounded responses, and specific error classes.
|
|
29
|
+
- Python 3.10 or later, using only the standard library at runtime.
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
Install from a repository checkout:
|
|
34
|
+
|
|
35
|
+
```powershell
|
|
36
|
+
git clone https://github.com/rteoo/inmet-forecast.git
|
|
37
|
+
cd inmet-forecast
|
|
38
|
+
python -m venv .venv
|
|
39
|
+
.venv\Scripts\Activate.ps1
|
|
40
|
+
python -m pip install .
|
|
41
|
+
python -m forecast 5218508
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Use `python -m pip install -e .` for an editable development install. The
|
|
45
|
+
distribution and console command are named `inmet-forecast`; the Python import
|
|
46
|
+
is `forecast`. The example uses Quirinópolis, Goiás, municipality code `5218508`.
|
|
47
|
+
|
|
48
|
+
## Python
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from forecast import InmetClient, fetch_forecast, normalize_forecast
|
|
52
|
+
|
|
53
|
+
# Quirinópolis, Goiás (IBGE municipality code).
|
|
54
|
+
raw = fetch_forecast(5218508, timeout=20)
|
|
55
|
+
records = normalize_forecast(raw)
|
|
56
|
+
|
|
57
|
+
for record in records:
|
|
58
|
+
print(record["date"], record["period"], record["resumo"])
|
|
59
|
+
|
|
60
|
+
# Reuse a configured client across requests.
|
|
61
|
+
client = InmetClient(timeout=30)
|
|
62
|
+
raw = client.get_forecast("5218508")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`fetch_forecast()` and `get_forecast()` return the original validated dictionary,
|
|
66
|
+
including base64 icons. `normalize_forecast()` returns chronological rows with
|
|
67
|
+
`municipality_code`, ISO `date`, and `period` (`morning`, `afternoon`, `night`, or
|
|
68
|
+
`daily`). Original INMET fields and Portuguese descriptions are retained. Embedded
|
|
69
|
+
images are excluded from normalized rows unless `include_icons=True`.
|
|
70
|
+
|
|
71
|
+
The first two dates currently contain `manha`, `tarde`, and `noite` objects; later
|
|
72
|
+
dates contain one daily object. Normalization detects the shape of each date
|
|
73
|
+
instead of assuming a fixed five-day horizon. Temperatures are Celsius and
|
|
74
|
+
humidity values are percentages; numeric values may be `None` when missing.
|
|
75
|
+
Dates arrive from INMET as `DD/MM/YYYY`.
|
|
76
|
+
|
|
77
|
+
## Command line
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
inmet-forecast 5218508
|
|
81
|
+
python -m forecast 5218508 --timeout 30
|
|
82
|
+
python -m forecast 5218508 --raw
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Default output is normalized UTF-8 JSON. `--raw` includes all original fields
|
|
86
|
+
and embedded images; `--include-icons` keeps images in normalized output.
|
|
87
|
+
Failures print an error to stderr and return exit status 1.
|
|
88
|
+
|
|
89
|
+
## Errors and service limits
|
|
90
|
+
|
|
91
|
+
Catch `InmetError` for service failures, or its specific subclasses:
|
|
92
|
+
`InmetHTTPError` (with `.status`), `InmetNetworkError`, and `InmetResponseError`.
|
|
93
|
+
Invalid codes and timeouts raise `ValueError` before making a request.
|
|
94
|
+
Responses are limited to 8 MiB, and must be nonempty UTF-8 JSON with valid forecast
|
|
95
|
+
entries. The timeout bounds individual socket operations, not total elapsed time.
|
|
96
|
+
There are no automatic retries or caches. Caller applications should cache
|
|
97
|
+
appropriately and label retrieval times.
|
|
98
|
+
|
|
99
|
+
Only forecasts are supported. They are not current station measurements.
|
|
100
|
+
The API has no moon-phase field in the response inspected on 2026-10-07.
|
|
101
|
+
Do not keep today's temperature header when showing tomorrow's forecast: use
|
|
102
|
+
the fields from the selected date/period.
|
|
103
|
+
|
|
104
|
+
## Source and verification
|
|
105
|
+
|
|
106
|
+
- [Forecast API example](https://apiprevmet3.inmet.gov.br/previsao/5218508)
|
|
107
|
+
- [Official forecast page](https://previsao.inmet.gov.br/5218508)
|
|
108
|
+
- [IBGE municipality](https://www.ibge.gov.br/cidades-e-estados/go/quirinopolis.html)
|
|
109
|
+
- [INMET forecast service](https://portal.inmet.gov.br/servicos/previs%C3%A3o-do-tempo)
|
|
110
|
+
- [API access contact](https://portal.inmet.gov.br/fale-conosco): api@inmet.gov.br
|
|
111
|
+
|
|
112
|
+
The official forecast frontend uses this API. An unauthenticated request returned
|
|
113
|
+
HTTP 200 on 2026-10-07; this is a point-in-time observation, not an authentication,
|
|
114
|
+
rate-limit, uptime, or schema guarantee. This project is an independent client
|
|
115
|
+
and is not affiliated with INMET. Data remains attributed to INMET; the MIT
|
|
116
|
+
license covers this client code, not a grant of rights over third-party data.
|
|
117
|
+
|
|
118
|
+
## Development
|
|
119
|
+
|
|
120
|
+
Tests use the standard library and a local HTTP server; they never call INMET.
|
|
121
|
+
|
|
122
|
+
```powershell
|
|
123
|
+
$env:PYTHONPATH = 'src'
|
|
124
|
+
python -W error::ResourceWarning -m unittest discover -s tests -v
|
|
125
|
+
python -m ruff check src tests
|
|
126
|
+
python -m build --no-isolation
|
|
127
|
+
python -m twine check dist/*
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The build and lint commands use tooling already installed on the host. Tests
|
|
131
|
+
shut down their server and close their files even on failure.
|
|
132
|
+
|
|
133
|
+
Verified on Windows/Python 3.14.6 on 2026-10-07: 18 tests passed from source and
|
|
134
|
+
from an installed wheel, Ruff passed, wheel/sdist builds and Twine checks passed.
|
|
135
|
+
The installed CLI fetched nine forecast rows for Quirinópolis. October 7's
|
|
136
|
+
afternoon forecast matched 19–36°C, 30–90% humidity, light NE-E winds, and the
|
|
137
|
+
portal's showers/thunderstorms description. Other Python versions and operating
|
|
138
|
+
systems have not been exercised locally.
|
|
139
|
+
|
|
140
|
+
## Publishing to PyPI
|
|
141
|
+
|
|
142
|
+
`.github/workflows/publish.yml` publishes when a GitHub release is published. It tests
|
|
143
|
+
the installed package on Python 3.10 through 3.14, checks lint and formatting,
|
|
144
|
+
builds and validates a wheel and source distribution, then uploads those same
|
|
145
|
+
artifacts using [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/).
|
|
146
|
+
No PyPI API token is needed. A manual workflow run performs validation only.
|
|
147
|
+
|
|
148
|
+
Before the first release:
|
|
149
|
+
|
|
150
|
+
1. Create the GitHub repository environment `pypi`. Configure required reviewers
|
|
151
|
+
and restrict its deployment tags to `v*` where the repository plan permits.
|
|
152
|
+
2. Register a [pending PyPI publisher](https://pypi.org/manage/account/publishing/)
|
|
153
|
+
with project name `inmet-forecast`, owner `rteoo`, repository `inmet-forecast`,
|
|
154
|
+
workflow filename `publish.yml`, and environment `pypi`.
|
|
155
|
+
3. Publish a GitHub release whose tag exactly matches `v` plus the version in
|
|
156
|
+
`pyproject.toml`, currently `v1.0.0`. The tagged commit must contain the workflow.
|
|
157
|
+
|
|
158
|
+
For later releases, update the package version before tagging. PyPI versions
|
|
159
|
+
cannot be overwritten. The workflow deliberately fails on an existing version
|
|
160
|
+
instead of silently skipping its upload. GitHub Actions execution and PyPI
|
|
161
|
+
publication have not been verified from this local checkout.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
This client is released under the [MIT License](LICENSE). Weather data remains
|
|
166
|
+
attributed to INMET. The [project icon](docs/inmet-forecast-icon.png) is an
|
|
167
|
+
independent weather mark; its design reference and generation prompt are recorded
|
|
168
|
+
in [docs/README.md](docs/README.md).
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "inmet-forecast"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "A dependency-free Python client for INMET municipality forecasts"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["inmet", "weather", "forecast", "brazil"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 5 - Production/Stable",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Atmospheric Science",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
inmet-forecast = "forecast.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.package-data]
|
|
29
|
+
forecast = ["py.typed"]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
target-version = "py310"
|
|
33
|
+
line-length = 100
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "UP"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Fetch and normalize official INMET municipality forecasts."""
|
|
2
|
+
|
|
3
|
+
from .client import InmetClient, fetch_forecast
|
|
4
|
+
from .errors import InmetError, InmetHTTPError, InmetNetworkError, InmetResponseError
|
|
5
|
+
from .forecast import normalize_forecast
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"InmetClient",
|
|
10
|
+
"InmetError",
|
|
11
|
+
"InmetHTTPError",
|
|
12
|
+
"InmetNetworkError",
|
|
13
|
+
"InmetResponseError",
|
|
14
|
+
"fetch_forecast",
|
|
15
|
+
"normalize_forecast",
|
|
16
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Command-line forecast output as UTF-8 JSON."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from .client import fetch_forecast
|
|
9
|
+
from .errors import InmetError
|
|
10
|
+
from .forecast import normalize_forecast
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(description="Fetch an INMET municipality forecast as JSON.")
|
|
15
|
+
parser.add_argument("municipality_code", help="Seven-digit IBGE code, e.g. 5218508")
|
|
16
|
+
parser.add_argument("--timeout", type=float, default=20.0, help="Socket timeout in seconds")
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--raw", action="store_true", help="Original JSON, including embedded icons"
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
"--include-icons", action="store_true", help="Keep icons in normalized rows"
|
|
22
|
+
)
|
|
23
|
+
args = parser.parse_args(argv)
|
|
24
|
+
try:
|
|
25
|
+
payload = fetch_forecast(args.municipality_code, timeout=args.timeout)
|
|
26
|
+
output = (
|
|
27
|
+
payload
|
|
28
|
+
if args.raw
|
|
29
|
+
else normalize_forecast(
|
|
30
|
+
payload, args.municipality_code, include_icons=args.include_icons
|
|
31
|
+
)
|
|
32
|
+
)
|
|
33
|
+
except (InmetError, ValueError) as error:
|
|
34
|
+
print(f"inmet-forecast: {error}", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
# Emit UTF-8 on Windows too, including when stdout is redirected.
|
|
37
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
38
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
39
|
+
print(json.dumps(output, ensure_ascii=False, indent=2))
|
|
40
|
+
return 0
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Small HTTPS client built entirely on the Python standard library."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
from http.client import HTTPException
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.error import HTTPError, URLError
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from .errors import InmetHTTPError, InmetNetworkError, InmetResponseError
|
|
11
|
+
from .forecast import municipality_code, validate_forecast
|
|
12
|
+
|
|
13
|
+
FORECAST_BASE_URL = "https://apiprevmet3.inmet.gov.br"
|
|
14
|
+
# ceiling: 8 MiB per response, including icons; review if INMET expands its forecast horizon.
|
|
15
|
+
MAX_RESPONSE_BYTES = 8 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InmetClient:
|
|
19
|
+
"""Fetch municipality forecasts with a finite per-socket timeout.
|
|
20
|
+
|
|
21
|
+
No credentials, persistent session, automatic retries, or caching are used.
|
|
22
|
+
``timeout`` is a socket-operation limit, not a total request deadline.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *, timeout: float = 20.0) -> None:
|
|
26
|
+
if (
|
|
27
|
+
isinstance(timeout, bool)
|
|
28
|
+
or not isinstance(timeout, (int, float))
|
|
29
|
+
or not math.isfinite(timeout)
|
|
30
|
+
or timeout <= 0
|
|
31
|
+
):
|
|
32
|
+
raise ValueError("Timeout must be a positive, finite number of seconds.")
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
|
|
35
|
+
def get_forecast(self, code: str | int) -> dict[str, Any]:
|
|
36
|
+
"""GET /previsao/{IBGE_CODE} and return validated, unmodified JSON."""
|
|
37
|
+
selected = municipality_code(code)
|
|
38
|
+
request = Request(
|
|
39
|
+
f"{FORECAST_BASE_URL}/previsao/{selected}",
|
|
40
|
+
headers={"Accept": "application/json", "User-Agent": "inmet-forecast/1.0.0"},
|
|
41
|
+
method="GET",
|
|
42
|
+
)
|
|
43
|
+
try:
|
|
44
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
45
|
+
if response.status != 200:
|
|
46
|
+
raise InmetHTTPError(response.status)
|
|
47
|
+
content_type = response.headers.get_content_type()
|
|
48
|
+
if content_type != "application/json" and not content_type.endswith("+json"):
|
|
49
|
+
raise InmetResponseError("INMET returned non-JSON content.")
|
|
50
|
+
declared_length = response.length
|
|
51
|
+
if declared_length is not None and declared_length > MAX_RESPONSE_BYTES:
|
|
52
|
+
raise InmetResponseError("INMET response exceeded the 8 MiB limit.")
|
|
53
|
+
body = response.read(MAX_RESPONSE_BYTES + 1)
|
|
54
|
+
if declared_length is not None and len(body) < declared_length:
|
|
55
|
+
raise InmetNetworkError(
|
|
56
|
+
"INMET connection ended before the response was complete."
|
|
57
|
+
)
|
|
58
|
+
except HTTPError as error:
|
|
59
|
+
status = error.code
|
|
60
|
+
error.close()
|
|
61
|
+
raise InmetHTTPError(status) from None
|
|
62
|
+
except (URLError, TimeoutError, OSError, HTTPException):
|
|
63
|
+
raise InmetNetworkError(
|
|
64
|
+
"Could not reach INMET. Check connectivity and retry; "
|
|
65
|
+
"increase timeout if the service is slow."
|
|
66
|
+
) from None
|
|
67
|
+
if not body.strip():
|
|
68
|
+
raise InmetResponseError("INMET returned an empty response.")
|
|
69
|
+
if len(body) > MAX_RESPONSE_BYTES:
|
|
70
|
+
raise InmetResponseError("INMET response exceeded the 8 MiB limit.")
|
|
71
|
+
try:
|
|
72
|
+
payload = json.loads(body.decode("utf-8-sig"))
|
|
73
|
+
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError):
|
|
74
|
+
raise InmetResponseError("INMET returned invalid UTF-8 JSON.") from None
|
|
75
|
+
return validate_forecast(payload, selected)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fetch_forecast(code: str | int, *, timeout: float = 20.0) -> dict[str, Any]:
|
|
79
|
+
"""Fetch raw forecast JSON using a temporary client."""
|
|
80
|
+
return InmetClient(timeout=timeout).get_forecast(code)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Public exceptions; response bodies are deliberately excluded from messages."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InmetError(Exception):
|
|
5
|
+
"""Base class for INMET transport and response failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InmetHTTPError(InmetError):
|
|
9
|
+
"""The API returned an unsuccessful HTTP status."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, status: int) -> None:
|
|
12
|
+
self.status = status
|
|
13
|
+
super().__init__(f"INMET returned HTTP {status}.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InmetNetworkError(InmetError):
|
|
17
|
+
"""The API could not be reached or the connection timed out."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InmetResponseError(InmetError):
|
|
21
|
+
"""The API response was empty, malformed, or incompatible."""
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Validate INMET's date-keyed JSON and flatten its two forecast shapes."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .errors import InmetResponseError
|
|
8
|
+
|
|
9
|
+
PERIODS = {"manha": "morning", "tarde": "afternoon", "noite": "night"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def municipality_code(value: str | int) -> str:
|
|
13
|
+
"""Return a seven-digit IBGE code, without accepting URL fragments."""
|
|
14
|
+
if isinstance(value, bool) or not isinstance(value, (str, int)):
|
|
15
|
+
raise ValueError("Municipality code must be a seven-digit IBGE code.")
|
|
16
|
+
code = str(value)
|
|
17
|
+
if len(code) != 7 or not code.isascii() or not code.isdigit():
|
|
18
|
+
raise ValueError("Municipality code must be a seven-digit IBGE code.")
|
|
19
|
+
return code
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _date(value: str) -> str:
|
|
23
|
+
try:
|
|
24
|
+
parsed = datetime.strptime(value, "%d/%m/%Y")
|
|
25
|
+
except (ValueError, TypeError):
|
|
26
|
+
raise InmetResponseError("INMET returned an invalid forecast date.") from None
|
|
27
|
+
if parsed.strftime("%d/%m/%Y") != value:
|
|
28
|
+
raise InmetResponseError("INMET returned an invalid forecast date.")
|
|
29
|
+
return parsed.date().isoformat()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _entry(value: Any) -> dict[str, Any]:
|
|
33
|
+
if not isinstance(value, dict):
|
|
34
|
+
raise InmetResponseError("INMET returned an invalid forecast entry.")
|
|
35
|
+
for key in ("uf", "entidade", "resumo"):
|
|
36
|
+
if not isinstance(value.get(key), str) or not value[key].strip():
|
|
37
|
+
raise InmetResponseError(f"INMET forecast is missing a valid {key} field.")
|
|
38
|
+
for key in ("temp_min", "temp_max", "umidade_min", "umidade_max"):
|
|
39
|
+
if key not in value:
|
|
40
|
+
raise InmetResponseError(f"INMET forecast is missing {key}.")
|
|
41
|
+
number = value[key]
|
|
42
|
+
if number is not None and (
|
|
43
|
+
isinstance(number, bool)
|
|
44
|
+
or not isinstance(number, (int, float))
|
|
45
|
+
or not math.isfinite(number)
|
|
46
|
+
):
|
|
47
|
+
raise InmetResponseError(f"INMET forecast has an invalid {key} value.")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_forecast(payload: Any, code: str) -> dict[str, Any]:
|
|
52
|
+
"""Validate the requested municipality while retaining all original fields."""
|
|
53
|
+
if not isinstance(payload, dict) or not isinstance(payload.get(code), dict):
|
|
54
|
+
raise InmetResponseError("INMET response does not contain the requested municipality.")
|
|
55
|
+
days = payload[code]
|
|
56
|
+
if not days:
|
|
57
|
+
raise InmetResponseError("INMET returned no forecasts for the requested municipality.")
|
|
58
|
+
for day, value in days.items():
|
|
59
|
+
_date(day)
|
|
60
|
+
if not isinstance(value, dict) or not value:
|
|
61
|
+
raise InmetResponseError("INMET returned an invalid forecast day.")
|
|
62
|
+
period_keys = set(value).intersection(PERIODS)
|
|
63
|
+
if period_keys:
|
|
64
|
+
if set(value) != period_keys:
|
|
65
|
+
raise InmetResponseError("INMET returned a mixed or unknown forecast period.")
|
|
66
|
+
for period in period_keys:
|
|
67
|
+
_entry(value[period])
|
|
68
|
+
else:
|
|
69
|
+
_entry(value)
|
|
70
|
+
return payload
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_forecast(
|
|
74
|
+
payload: dict[str, Any],
|
|
75
|
+
code: str | int | None = None,
|
|
76
|
+
*,
|
|
77
|
+
include_icons: bool = False,
|
|
78
|
+
) -> list[dict[str, Any]]:
|
|
79
|
+
"""Return chronological rows with ISO dates and English period identifiers.
|
|
80
|
+
|
|
81
|
+
Original INMET field names and Portuguese descriptions are preserved. Embedded
|
|
82
|
+
images are omitted by default. The input payload is never modified.
|
|
83
|
+
"""
|
|
84
|
+
if code is None:
|
|
85
|
+
if not isinstance(payload, dict) or len(payload) != 1:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"Supply a municipality code for a response with multiple municipalities."
|
|
88
|
+
)
|
|
89
|
+
code = next(iter(payload))
|
|
90
|
+
selected = municipality_code(code)
|
|
91
|
+
days = validate_forecast(payload, selected)[selected]
|
|
92
|
+
records = []
|
|
93
|
+
for day in sorted(days, key=_date):
|
|
94
|
+
value = days[day]
|
|
95
|
+
entries = (
|
|
96
|
+
[(PERIODS[period], value[period]) for period in PERIODS if period in value]
|
|
97
|
+
if set(value).intersection(PERIODS)
|
|
98
|
+
else [("daily", value)]
|
|
99
|
+
)
|
|
100
|
+
for period, entry in entries:
|
|
101
|
+
record = {
|
|
102
|
+
key: item
|
|
103
|
+
for key, item in entry.items()
|
|
104
|
+
if include_icons or not (isinstance(item, str) and item.startswith("data:image/"))
|
|
105
|
+
}
|
|
106
|
+
record.update(municipality_code=selected, date=_date(day), period=period)
|
|
107
|
+
records.append(record)
|
|
108
|
+
return records
|
|
File without changes
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inmet-forecast
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A dependency-free Python client for INMET municipality forecasts
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: inmet,weather,forecast,brazil
|
|
7
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
11
|
+
Classifier: Typing :: Typed
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# inmet-forecast
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="docs/inmet-forecast-icon.png" width="128" alt="inmet-forecast weather icon: sun, cloud, and rain">
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<p align="center">
|
|
24
|
+
A dependency-free Python client for INMET's Brazilian municipality forecasts,
|
|
25
|
+
with raw API data, normalized records, and a JSON command line.
|
|
26
|
+
</p>
|
|
27
|
+
|
|
28
|
+
<p align="center">
|
|
29
|
+
<a href="https://github.com/rteoo/inmet-forecast/actions/workflows/publish.yml"><img src="https://github.com/rteoo/inmet-forecast/actions/workflows/publish.yml/badge.svg" alt="Publishing workflow status"></a>
|
|
30
|
+
<img src="https://img.shields.io/badge/python-3.10%2B-blue.svg" alt="Python 3.10 or later">
|
|
31
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
Fetch forecasts by IBGE municipality code from Python or the command line.
|
|
35
|
+
Keep INMET's original JSON or turn its period-based and daily entries into
|
|
36
|
+
chronological records without losing Portuguese descriptions or unknown fields.
|
|
37
|
+
|
|
38
|
+
## Highlights
|
|
39
|
+
|
|
40
|
+
- Municipality forecasts from INMET's forecast API, including temperature,
|
|
41
|
+
humidity, wind, weather descriptions, sunrise, and sunset when supplied.
|
|
42
|
+
- Raw responses and normalized morning, afternoon, night, and daily records.
|
|
43
|
+
- UTF-8 JSON output through `inmet-forecast` or `python -m forecast`.
|
|
44
|
+
- Configurable socket timeouts, bounded responses, and specific error classes.
|
|
45
|
+
- Python 3.10 or later, using only the standard library at runtime.
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
Install from a repository checkout:
|
|
50
|
+
|
|
51
|
+
```powershell
|
|
52
|
+
git clone https://github.com/rteoo/inmet-forecast.git
|
|
53
|
+
cd inmet-forecast
|
|
54
|
+
python -m venv .venv
|
|
55
|
+
.venv\Scripts\Activate.ps1
|
|
56
|
+
python -m pip install .
|
|
57
|
+
python -m forecast 5218508
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Use `python -m pip install -e .` for an editable development install. The
|
|
61
|
+
distribution and console command are named `inmet-forecast`; the Python import
|
|
62
|
+
is `forecast`. The example uses Quirinópolis, Goiás, municipality code `5218508`.
|
|
63
|
+
|
|
64
|
+
## Python
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from forecast import InmetClient, fetch_forecast, normalize_forecast
|
|
68
|
+
|
|
69
|
+
# Quirinópolis, Goiás (IBGE municipality code).
|
|
70
|
+
raw = fetch_forecast(5218508, timeout=20)
|
|
71
|
+
records = normalize_forecast(raw)
|
|
72
|
+
|
|
73
|
+
for record in records:
|
|
74
|
+
print(record["date"], record["period"], record["resumo"])
|
|
75
|
+
|
|
76
|
+
# Reuse a configured client across requests.
|
|
77
|
+
client = InmetClient(timeout=30)
|
|
78
|
+
raw = client.get_forecast("5218508")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`fetch_forecast()` and `get_forecast()` return the original validated dictionary,
|
|
82
|
+
including base64 icons. `normalize_forecast()` returns chronological rows with
|
|
83
|
+
`municipality_code`, ISO `date`, and `period` (`morning`, `afternoon`, `night`, or
|
|
84
|
+
`daily`). Original INMET fields and Portuguese descriptions are retained. Embedded
|
|
85
|
+
images are excluded from normalized rows unless `include_icons=True`.
|
|
86
|
+
|
|
87
|
+
The first two dates currently contain `manha`, `tarde`, and `noite` objects; later
|
|
88
|
+
dates contain one daily object. Normalization detects the shape of each date
|
|
89
|
+
instead of assuming a fixed five-day horizon. Temperatures are Celsius and
|
|
90
|
+
humidity values are percentages; numeric values may be `None` when missing.
|
|
91
|
+
Dates arrive from INMET as `DD/MM/YYYY`.
|
|
92
|
+
|
|
93
|
+
## Command line
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
inmet-forecast 5218508
|
|
97
|
+
python -m forecast 5218508 --timeout 30
|
|
98
|
+
python -m forecast 5218508 --raw
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Default output is normalized UTF-8 JSON. `--raw` includes all original fields
|
|
102
|
+
and embedded images; `--include-icons` keeps images in normalized output.
|
|
103
|
+
Failures print an error to stderr and return exit status 1.
|
|
104
|
+
|
|
105
|
+
## Errors and service limits
|
|
106
|
+
|
|
107
|
+
Catch `InmetError` for service failures, or its specific subclasses:
|
|
108
|
+
`InmetHTTPError` (with `.status`), `InmetNetworkError`, and `InmetResponseError`.
|
|
109
|
+
Invalid codes and timeouts raise `ValueError` before making a request.
|
|
110
|
+
Responses are limited to 8 MiB, and must be nonempty UTF-8 JSON with valid forecast
|
|
111
|
+
entries. The timeout bounds individual socket operations, not total elapsed time.
|
|
112
|
+
There are no automatic retries or caches. Caller applications should cache
|
|
113
|
+
appropriately and label retrieval times.
|
|
114
|
+
|
|
115
|
+
Only forecasts are supported. They are not current station measurements.
|
|
116
|
+
The API has no moon-phase field in the response inspected on 2026-10-07.
|
|
117
|
+
Do not keep today's temperature header when showing tomorrow's forecast: use
|
|
118
|
+
the fields from the selected date/period.
|
|
119
|
+
|
|
120
|
+
## Source and verification
|
|
121
|
+
|
|
122
|
+
- [Forecast API example](https://apiprevmet3.inmet.gov.br/previsao/5218508)
|
|
123
|
+
- [Official forecast page](https://previsao.inmet.gov.br/5218508)
|
|
124
|
+
- [IBGE municipality](https://www.ibge.gov.br/cidades-e-estados/go/quirinopolis.html)
|
|
125
|
+
- [INMET forecast service](https://portal.inmet.gov.br/servicos/previs%C3%A3o-do-tempo)
|
|
126
|
+
- [API access contact](https://portal.inmet.gov.br/fale-conosco): api@inmet.gov.br
|
|
127
|
+
|
|
128
|
+
The official forecast frontend uses this API. An unauthenticated request returned
|
|
129
|
+
HTTP 200 on 2026-10-07; this is a point-in-time observation, not an authentication,
|
|
130
|
+
rate-limit, uptime, or schema guarantee. This project is an independent client
|
|
131
|
+
and is not affiliated with INMET. Data remains attributed to INMET; the MIT
|
|
132
|
+
license covers this client code, not a grant of rights over third-party data.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
Tests use the standard library and a local HTTP server; they never call INMET.
|
|
137
|
+
|
|
138
|
+
```powershell
|
|
139
|
+
$env:PYTHONPATH = 'src'
|
|
140
|
+
python -W error::ResourceWarning -m unittest discover -s tests -v
|
|
141
|
+
python -m ruff check src tests
|
|
142
|
+
python -m build --no-isolation
|
|
143
|
+
python -m twine check dist/*
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The build and lint commands use tooling already installed on the host. Tests
|
|
147
|
+
shut down their server and close their files even on failure.
|
|
148
|
+
|
|
149
|
+
Verified on Windows/Python 3.14.6 on 2026-10-07: 18 tests passed from source and
|
|
150
|
+
from an installed wheel, Ruff passed, wheel/sdist builds and Twine checks passed.
|
|
151
|
+
The installed CLI fetched nine forecast rows for Quirinópolis. October 7's
|
|
152
|
+
afternoon forecast matched 19–36°C, 30–90% humidity, light NE-E winds, and the
|
|
153
|
+
portal's showers/thunderstorms description. Other Python versions and operating
|
|
154
|
+
systems have not been exercised locally.
|
|
155
|
+
|
|
156
|
+
## Publishing to PyPI
|
|
157
|
+
|
|
158
|
+
`.github/workflows/publish.yml` publishes when a GitHub release is published. It tests
|
|
159
|
+
the installed package on Python 3.10 through 3.14, checks lint and formatting,
|
|
160
|
+
builds and validates a wheel and source distribution, then uploads those same
|
|
161
|
+
artifacts using [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/).
|
|
162
|
+
No PyPI API token is needed. A manual workflow run performs validation only.
|
|
163
|
+
|
|
164
|
+
Before the first release:
|
|
165
|
+
|
|
166
|
+
1. Create the GitHub repository environment `pypi`. Configure required reviewers
|
|
167
|
+
and restrict its deployment tags to `v*` where the repository plan permits.
|
|
168
|
+
2. Register a [pending PyPI publisher](https://pypi.org/manage/account/publishing/)
|
|
169
|
+
with project name `inmet-forecast`, owner `rteoo`, repository `inmet-forecast`,
|
|
170
|
+
workflow filename `publish.yml`, and environment `pypi`.
|
|
171
|
+
3. Publish a GitHub release whose tag exactly matches `v` plus the version in
|
|
172
|
+
`pyproject.toml`, currently `v1.0.0`. The tagged commit must contain the workflow.
|
|
173
|
+
|
|
174
|
+
For later releases, update the package version before tagging. PyPI versions
|
|
175
|
+
cannot be overwritten. The workflow deliberately fails on an existing version
|
|
176
|
+
instead of silently skipping its upload. GitHub Actions execution and PyPI
|
|
177
|
+
publication have not been verified from this local checkout.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
This client is released under the [MIT License](LICENSE). Weather data remains
|
|
182
|
+
attributed to INMET. The [project icon](docs/inmet-forecast-icon.png) is an
|
|
183
|
+
independent weather mark; its design reference and generation prompt are recorded
|
|
184
|
+
in [docs/README.md](docs/README.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/forecast/__init__.py
|
|
5
|
+
src/forecast/__main__.py
|
|
6
|
+
src/forecast/cli.py
|
|
7
|
+
src/forecast/client.py
|
|
8
|
+
src/forecast/errors.py
|
|
9
|
+
src/forecast/forecast.py
|
|
10
|
+
src/forecast/py.typed
|
|
11
|
+
src/inmet_forecast.egg-info/PKG-INFO
|
|
12
|
+
src/inmet_forecast.egg-info/SOURCES.txt
|
|
13
|
+
src/inmet_forecast.egg-info/dependency_links.txt
|
|
14
|
+
src/inmet_forecast.egg-info/entry_points.txt
|
|
15
|
+
src/inmet_forecast.egg-info/top_level.txt
|
|
16
|
+
tests/test_forecast.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forecast
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import contextmanager, redirect_stderr, redirect_stdout
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from forecast import (
|
|
12
|
+
InmetClient,
|
|
13
|
+
InmetHTTPError,
|
|
14
|
+
InmetNetworkError,
|
|
15
|
+
InmetResponseError,
|
|
16
|
+
fetch_forecast,
|
|
17
|
+
normalize_forecast,
|
|
18
|
+
)
|
|
19
|
+
from forecast.cli import main
|
|
20
|
+
|
|
21
|
+
CODE = "5218508"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def entry(**updates):
|
|
25
|
+
data = {
|
|
26
|
+
"uf": "GO",
|
|
27
|
+
"entidade": "Quirinópolis",
|
|
28
|
+
"resumo": "Muitas nuvens com chuva isolada",
|
|
29
|
+
"temp_min": 19,
|
|
30
|
+
"temp_max": 36,
|
|
31
|
+
"umidade_min": 30,
|
|
32
|
+
"umidade_max": 90,
|
|
33
|
+
"dir_vento": "SE-S",
|
|
34
|
+
"int_vento": "Fracos",
|
|
35
|
+
"icone": "data:image/png;base64,fixture",
|
|
36
|
+
}
|
|
37
|
+
return dict(data, **updates)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def forecast():
|
|
41
|
+
# Date ordering intentionally crosses months and differs from insertion order.
|
|
42
|
+
return {
|
|
43
|
+
CODE: {
|
|
44
|
+
"01/11/2026": entry(temp_min=23, temp_max=39),
|
|
45
|
+
"31/10/2026": {
|
|
46
|
+
"noite": entry(),
|
|
47
|
+
"manha": entry(resumo="Poucas nuvens", temp_min=21, temp_max=39),
|
|
48
|
+
"tarde": entry(resumo="Muitas nuvens com pancadas de chuva e trovoadas"),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def serve(body, *, status=200, content_type="application/json", delay=0, truncated=False):
|
|
56
|
+
requests = []
|
|
57
|
+
|
|
58
|
+
class Handler(BaseHTTPRequestHandler):
|
|
59
|
+
def do_GET(self):
|
|
60
|
+
requests.append((self.command, self.path, self.headers.get("Accept")))
|
|
61
|
+
self.send_response(status)
|
|
62
|
+
self.send_header("Content-Type", content_type)
|
|
63
|
+
self.send_header("Content-Length", str(len(body) + (100 if truncated else 0)))
|
|
64
|
+
self.end_headers()
|
|
65
|
+
if delay:
|
|
66
|
+
time.sleep(delay)
|
|
67
|
+
try:
|
|
68
|
+
self.wfile.write(body)
|
|
69
|
+
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
|
|
70
|
+
pass # The timeout test deliberately closes the client's socket.
|
|
71
|
+
|
|
72
|
+
def log_message(self, *args):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
76
|
+
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01})
|
|
77
|
+
thread.start()
|
|
78
|
+
try:
|
|
79
|
+
with patch("forecast.client.FORECAST_BASE_URL", f"http://127.0.0.1:{server.server_port}"):
|
|
80
|
+
yield requests
|
|
81
|
+
finally:
|
|
82
|
+
server.shutdown()
|
|
83
|
+
server.server_close()
|
|
84
|
+
thread.join(timeout=5)
|
|
85
|
+
if thread.is_alive():
|
|
86
|
+
raise RuntimeError("Test HTTP server did not stop.")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ClientTests(unittest.TestCase):
|
|
90
|
+
def test_real_http_get_preserves_unicode_payload_and_headers(self):
|
|
91
|
+
payload = forecast()
|
|
92
|
+
with serve(json.dumps(payload, ensure_ascii=False).encode("utf-8")) as requests:
|
|
93
|
+
self.assertEqual(fetch_forecast(5218508), payload)
|
|
94
|
+
self.assertEqual(requests, [("GET", "/previsao/5218508", "application/json")])
|
|
95
|
+
|
|
96
|
+
def test_http_errors_expose_status_without_response_body(self):
|
|
97
|
+
for status in (403, 404, 429, 500):
|
|
98
|
+
with self.subTest(status=status), serve(b"private upstream detail", status=status):
|
|
99
|
+
with self.assertRaises(InmetHTTPError) as caught:
|
|
100
|
+
fetch_forecast(CODE)
|
|
101
|
+
self.assertEqual(caught.exception.status, status)
|
|
102
|
+
self.assertNotIn("private", str(caught.exception))
|
|
103
|
+
|
|
104
|
+
def test_non_json_empty_malformed_and_wrong_city_responses(self):
|
|
105
|
+
scenarios = [
|
|
106
|
+
(b"<html>upstream error</html>", "text/html"),
|
|
107
|
+
(b"", "application/json"),
|
|
108
|
+
(b"not json", "application/json"),
|
|
109
|
+
(b"\xff", "application/json"),
|
|
110
|
+
(b"[]", "application/json"),
|
|
111
|
+
(b'{"5218508":{}}', "application/json"),
|
|
112
|
+
(b'{"5300108":{}}', "application/json"),
|
|
113
|
+
]
|
|
114
|
+
for body, content_type in scenarios:
|
|
115
|
+
with self.subTest(body=body), serve(body, content_type=content_type):
|
|
116
|
+
with self.assertRaises(InmetResponseError):
|
|
117
|
+
fetch_forecast(CODE)
|
|
118
|
+
|
|
119
|
+
def test_utf8_bom_and_json_suffix_content_type(self):
|
|
120
|
+
body = b"\xef\xbb\xbf" + json.dumps(forecast()).encode()
|
|
121
|
+
with serve(body, content_type="application/vnd.inmet+json"):
|
|
122
|
+
self.assertEqual(fetch_forecast(CODE), forecast())
|
|
123
|
+
|
|
124
|
+
def test_no_content_is_not_a_successful_forecast(self):
|
|
125
|
+
with serve(b"", status=204):
|
|
126
|
+
with self.assertRaises(InmetHTTPError) as caught:
|
|
127
|
+
fetch_forecast(CODE)
|
|
128
|
+
self.assertEqual(caught.exception.status, 204)
|
|
129
|
+
|
|
130
|
+
def test_read_timeout_becomes_network_error_and_does_not_retry(self):
|
|
131
|
+
with serve(json.dumps(forecast()).encode(), delay=0.1) as requests:
|
|
132
|
+
with self.assertRaises(InmetNetworkError):
|
|
133
|
+
fetch_forecast(CODE, timeout=0.02)
|
|
134
|
+
self.assertEqual(len(requests), 1)
|
|
135
|
+
|
|
136
|
+
def test_truncated_http_body_becomes_network_error(self):
|
|
137
|
+
with serve(b"{", truncated=True):
|
|
138
|
+
with self.assertRaises(InmetNetworkError):
|
|
139
|
+
fetch_forecast(CODE)
|
|
140
|
+
|
|
141
|
+
def test_response_size_limit(self):
|
|
142
|
+
with patch("forecast.client.MAX_RESPONSE_BYTES", 8), serve(b"123456789"):
|
|
143
|
+
with self.assertRaisesRegex(InmetResponseError, "limit"):
|
|
144
|
+
fetch_forecast(CODE)
|
|
145
|
+
|
|
146
|
+
def test_invalid_inputs_make_no_request(self):
|
|
147
|
+
with patch("forecast.client.urlopen") as opener:
|
|
148
|
+
for code in (True, None, 1.0, "5218508/x", "1234567", "", "123456"):
|
|
149
|
+
with self.subTest(code=code), self.assertRaises(ValueError):
|
|
150
|
+
fetch_forecast(code)
|
|
151
|
+
for timeout in (True, None, 0, -1, "20", float("nan"), float("inf")):
|
|
152
|
+
with self.subTest(timeout=timeout), self.assertRaises(ValueError):
|
|
153
|
+
InmetClient(timeout=timeout)
|
|
154
|
+
opener.assert_not_called()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class NormalizationTests(unittest.TestCase):
|
|
158
|
+
def test_mixed_shapes_sort_by_calendar_date_and_preserve_period_data(self):
|
|
159
|
+
rows = normalize_forecast(forecast())
|
|
160
|
+
self.assertEqual(
|
|
161
|
+
[(row["date"], row["period"]) for row in rows],
|
|
162
|
+
[
|
|
163
|
+
("2026-10-31", "morning"),
|
|
164
|
+
("2026-10-31", "afternoon"),
|
|
165
|
+
("2026-10-31", "night"),
|
|
166
|
+
("2026-11-01", "daily"),
|
|
167
|
+
],
|
|
168
|
+
)
|
|
169
|
+
self.assertEqual(rows[0]["temp_min"], 21)
|
|
170
|
+
self.assertEqual(rows[0]["resumo"], "Poucas nuvens")
|
|
171
|
+
self.assertEqual(rows[2]["dir_vento"], "SE-S")
|
|
172
|
+
self.assertEqual(rows[3]["municipality_code"], CODE)
|
|
173
|
+
|
|
174
|
+
def test_icons_are_optional_and_raw_payload_is_not_mutated(self):
|
|
175
|
+
payload = forecast()
|
|
176
|
+
before = copy.deepcopy(payload)
|
|
177
|
+
self.assertNotIn("icone", normalize_forecast(payload)[0])
|
|
178
|
+
self.assertIn("icone", normalize_forecast(payload, include_icons=True)[0])
|
|
179
|
+
self.assertEqual(payload, before)
|
|
180
|
+
|
|
181
|
+
def test_null_numeric_values_are_preserved(self):
|
|
182
|
+
rows = normalize_forecast({CODE: {"07/10/2026": entry(temp_min=None)}})
|
|
183
|
+
self.assertIsNone(rows[0]["temp_min"])
|
|
184
|
+
|
|
185
|
+
def test_unknown_fields_are_retained(self):
|
|
186
|
+
rows = normalize_forecast({CODE: {"07/10/2026": entry(new_field="future")}})
|
|
187
|
+
self.assertEqual(rows[0]["new_field"], "future")
|
|
188
|
+
|
|
189
|
+
def test_partial_period_day_is_supported(self):
|
|
190
|
+
rows = normalize_forecast({CODE: {"07/10/2026": {"noite": entry()}}})
|
|
191
|
+
self.assertEqual(rows[0]["period"], "night")
|
|
192
|
+
|
|
193
|
+
def test_malformed_dates_and_entries_are_rejected(self):
|
|
194
|
+
for day, value in [
|
|
195
|
+
("31/02/2026", entry()),
|
|
196
|
+
("7/10/2026", entry()),
|
|
197
|
+
("07/10/2026", {}),
|
|
198
|
+
("07/10/2026", {"manha": entry(), "other": {}}),
|
|
199
|
+
("07/10/2026", entry(resumo="")),
|
|
200
|
+
("07/10/2026", entry(temp_min="19")),
|
|
201
|
+
("07/10/2026", entry(temp_max=float("nan"))),
|
|
202
|
+
("07/10/2026", entry(umidade_min=True)),
|
|
203
|
+
]:
|
|
204
|
+
with self.subTest(day=day, value=value), self.assertRaises(InmetResponseError):
|
|
205
|
+
normalize_forecast({CODE: {day: value}})
|
|
206
|
+
|
|
207
|
+
def test_multiple_cities_require_explicit_selection(self):
|
|
208
|
+
payload = dict(forecast(), **{"5300108": {"07/10/2026": entry(entidade="Brasília")}})
|
|
209
|
+
with self.assertRaises(ValueError):
|
|
210
|
+
normalize_forecast(payload)
|
|
211
|
+
self.assertEqual(normalize_forecast(payload, CODE)[0]["entidade"], "Quirinópolis")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class CLITests(unittest.TestCase):
|
|
215
|
+
def test_normalized_and_raw_cli_json(self):
|
|
216
|
+
with patch("forecast.cli.fetch_forecast", return_value=forecast()):
|
|
217
|
+
for extra, expected in [([], normalize_forecast(forecast())), (["--raw"], forecast())]:
|
|
218
|
+
with self.subTest(extra=extra), redirect_stdout(io.StringIO()) as output:
|
|
219
|
+
self.assertEqual(main([CODE, *extra]), 0)
|
|
220
|
+
self.assertEqual(json.loads(output.getvalue()), expected)
|
|
221
|
+
|
|
222
|
+
def test_cli_failure_returns_nonzero_without_traceback(self):
|
|
223
|
+
with patch("forecast.cli.fetch_forecast", side_effect=InmetHTTPError(429)):
|
|
224
|
+
with redirect_stderr(io.StringIO()) as error, redirect_stdout(io.StringIO()) as output:
|
|
225
|
+
self.assertEqual(main([CODE]), 1)
|
|
226
|
+
self.assertIn("HTTP 429", error.getvalue())
|
|
227
|
+
self.assertEqual(output.getvalue(), "")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
unittest.main()
|