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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ inmet-forecast = forecast.cli:main
@@ -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()