osmsg 0.3.0__tar.gz → 1.0.2__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.
Files changed (40) hide show
  1. osmsg-1.0.2/PKG-INFO +169 -0
  2. osmsg-1.0.2/README.md +137 -0
  3. osmsg-1.0.2/osmsg/__init__.py +95 -0
  4. osmsg-1.0.2/osmsg/__version__.py +1 -0
  5. osmsg-1.0.2/osmsg/_http.py +41 -0
  6. osmsg-1.0.2/osmsg/auth.py +118 -0
  7. osmsg-1.0.2/osmsg/boundary.py +37 -0
  8. osmsg-1.0.2/osmsg/cli.py +267 -0
  9. osmsg-1.0.2/osmsg/db/__init__.py +43 -0
  10. osmsg-1.0.2/osmsg/db/ingest.py +144 -0
  11. osmsg-1.0.2/osmsg/db/queries.py +265 -0
  12. osmsg-1.0.2/osmsg/db/schema.py +111 -0
  13. osmsg-1.0.2/osmsg/exceptions.py +37 -0
  14. osmsg-1.0.2/osmsg/export/__init__.py +19 -0
  15. osmsg-1.0.2/osmsg/export/csv.py +34 -0
  16. osmsg-1.0.2/osmsg/export/json.py +14 -0
  17. osmsg-1.0.2/osmsg/export/markdown.py +129 -0
  18. osmsg-1.0.2/osmsg/export/parquet.py +64 -0
  19. osmsg-1.0.2/osmsg/export/psql.py +89 -0
  20. osmsg-1.0.2/osmsg/fetch.py +49 -0
  21. osmsg-1.0.2/osmsg/geofabrik.py +41 -0
  22. osmsg-1.0.2/osmsg/handlers.py +205 -0
  23. osmsg-1.0.2/osmsg/models.py +143 -0
  24. osmsg-1.0.2/osmsg/pipeline.py +440 -0
  25. osmsg-1.0.2/osmsg/replication.py +162 -0
  26. osmsg-1.0.2/osmsg/tm.py +71 -0
  27. osmsg-1.0.2/osmsg/ui.py +65 -0
  28. osmsg-1.0.2/osmsg/workers.py +97 -0
  29. osmsg-1.0.2/pyproject.toml +113 -0
  30. osmsg-0.3.0/PKG-INFO +0 -104
  31. osmsg-0.3.0/README.md +0 -82
  32. osmsg-0.3.0/osmsg/__version__.py +0 -1
  33. osmsg-0.3.0/osmsg/app.py +0 -1550
  34. osmsg-0.3.0/osmsg/changefiles.py +0 -231
  35. osmsg-0.3.0/osmsg/changesets.py +0 -156
  36. osmsg-0.3.0/osmsg/login.py +0 -170
  37. osmsg-0.3.0/osmsg/utils.py +0 -845
  38. osmsg-0.3.0/pyproject.toml +0 -142
  39. {osmsg-0.3.0 → osmsg-1.0.2}/LICENSE +0 -0
  40. /osmsg-0.3.0/osmsg/__init__.py → /osmsg-1.0.2/osmsg/py.typed +0 -0
osmsg-1.0.2/PKG-INFO ADDED
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: osmsg
3
+ Version: 1.0.2
4
+ Summary: OpenStreetMap Stats Generator: Commandline
5
+ Keywords: osm,stats,commandline,openstreetmap
6
+ Author: Kshitij Raj Sharma
7
+ Author-email: Kshitij Raj Sharma <skshitizraj@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Topic :: Utilities
11
+ Classifier: Topic :: Scientific/Engineering :: GIS
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Requires-Dist: duckdb>=1.5.2
16
+ Requires-Dist: osmium>=4.3.1
17
+ Requires-Dist: platformdirs>=4.5.1
18
+ Requires-Dist: pyarrow>=24.0.0
19
+ Requires-Dist: pydantic>=2.13.3
20
+ Requires-Dist: python-dotenv>=1.2.2
21
+ Requires-Dist: pytz>=2024.1
22
+ Requires-Dist: requests>=2.32.5
23
+ Requires-Dist: rich>=13.0
24
+ Requires-Dist: shapely>=2.1.2
25
+ Requires-Dist: typer>=0.25.0
26
+ Requires-Dist: typer-config[yaml]>=1.5.1
27
+ Requires-Python: >=3.11
28
+ Project-URL: documentation, https://github.com/osgeonepal/osmsg
29
+ Project-URL: homepage, https://github.com/osgeonepal/osmsg
30
+ Project-URL: repository, https://github.com/osgeonepal/osmsg
31
+ Description-Content-Type: text/markdown
32
+
33
+ # osmsg
34
+
35
+ [![CI](https://github.com/osgeonepal/osmsg/actions/workflows/ci.yml/badge.svg)](https://github.com/osgeonepal/osmsg/actions/workflows/ci.yml)
36
+ [![Docker](https://github.com/osgeonepal/osmsg/actions/workflows/docker.yml/badge.svg)](https://github.com/osgeonepal/osmsg/actions/workflows/docker.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/osmsg.svg)](https://pypi.org/project/osmsg/)
38
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
40
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
41
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
42
+ [![Container](https://img.shields.io/badge/ghcr.io-osgeonepal%2Fosmsg-2496ED?logo=docker)](https://github.com/osgeonepal/osmsg/pkgs/container/osmsg)
43
+
44
+ **OpenStreetMap Stats Generator.** A tiny CLI (and Python library) that turns OSM history into per-user counts of nodes, ways, and relations created, modified, or deleted, written to parquet, csv, json, markdown, or Postgres.
45
+
46
+ A Project of [OSGeo Nepal](https://osgeonepal.org).
47
+
48
+ ## What you get
49
+
50
+ - Per-user create/modify/delete counts over any time window.
51
+ - Tag and hashtag breakdowns (e.g. `building`, `#hotosm`).
52
+ - Country and custom-boundary filters via Geofabrik.
53
+ - Cron-friendly resume with `--update`.
54
+ - Outputs you can query: parquet, csv, json, markdown, DuckDB, Postgres.
55
+
56
+ ## Install
57
+
58
+ Pick the one that fits how you work.
59
+
60
+ ```bash
61
+ pip install osmsg # into your project
62
+ uv tool install osmsg # standalone CLI
63
+ docker run --rm -v "$PWD:/work" -w /work ghcr.io/osgeonepal/osmsg:latest --last hour
64
+ ```
65
+
66
+ ## Quick start
67
+
68
+ ```bash
69
+ osmsg --last hour # planet, last hour
70
+ osmsg --last day --tags building # last day with a tag breakdown
71
+ osmsg --hashtags hotosm --last day # only changesets tagged #hotosm
72
+ ```
73
+
74
+ That's it. A `stats.duckdb` and a `stats.parquet` show up in your current folder.
75
+
76
+ ## Tutorials
77
+
78
+ ### 1. Stats for a country
79
+
80
+ ```bash
81
+ osmsg --country nepal --last day
82
+ ```
83
+
84
+ `--country` resolves through Geofabrik and needs an OSM account. Set `OSM_USERNAME` and `OSM_PASSWORD` in your shell or a `.env` file:
85
+
86
+ ```bash
87
+ export OSM_USERNAME=you
88
+ export OSM_PASSWORD=secret
89
+ ```
90
+
91
+ ### 2. A custom date range with summaries
92
+
93
+ ```bash
94
+ osmsg --start "2026-04-01" --end "2026-04-08" \
95
+ --tags building --tags highway --summary
96
+ ```
97
+
98
+ `--summary` adds a daily rollup file alongside the per-changeset stats.
99
+
100
+ ### 3. Run on a schedule
101
+
102
+ ```bash
103
+ osmsg --country nepal --update # picks up where the last run stopped
104
+ ```
105
+
106
+ Drop that into cron or a GitHub Actions schedule. State is stored inside the DuckDB file, so reruns are safe.
107
+
108
+ ### 4. Query the output
109
+
110
+ ```bash
111
+ duckdb stats.duckdb -c "SELECT username, SUM(nodes_created) AS n
112
+ FROM users JOIN changeset_stats USING (uid)
113
+ GROUP BY username ORDER BY n DESC LIMIT 10"
114
+ ```
115
+
116
+ Same schema in DuckDB and Postgres: `users`, `changesets`, `changeset_stats`, `state`.
117
+
118
+ ### 5. Use it as a library
119
+
120
+ ```python
121
+ from datetime import datetime, UTC
122
+ from osmsg import RunConfig, run
123
+
124
+ result = run(RunConfig(
125
+ name="nepal",
126
+ countries=["nepal"],
127
+ start_date=datetime(2026, 4, 25, tzinfo=UTC),
128
+ end_date=datetime(2026, 4, 26, tzinfo=UTC),
129
+ ))
130
+ print(result["files"]["parquet"])
131
+ ```
132
+
133
+ Same pipeline as the CLI.
134
+
135
+ ### 6. Long flag lists? Use a config
136
+
137
+ ```bash
138
+ osmsg --config nepal.yaml
139
+ ```
140
+
141
+ Any flag works as a YAML key. See [docs/Manual.md](./docs/Manual.md) for the full list.
142
+
143
+ ## Output formats
144
+
145
+ Every run writes `stats.duckdb` (or `<--name>.duckdb`) plus the formats you ask for via `-f parquet|csv|json|markdown|psql`. Parquet is the default. Open it with duckdb, polars, pandas, anything.
146
+
147
+ ## Documentation
148
+
149
+ - [Installation](./docs/Installation.md)
150
+ - [Manual](./docs/Manual.md) (every flag, with examples)
151
+ - [Version control / release notes](./docs/Version_control.md)
152
+
153
+ ## Contributing
154
+
155
+ Pull requests are welcome. Quick path:
156
+
157
+ ```bash
158
+ git clone https://github.com/osgeonepal/osmsg && cd osmsg
159
+ git switch develop
160
+ uv sync
161
+ uv run pre-commit install
162
+ uv run pytest -m "not network"
163
+ ```
164
+
165
+ Please read [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md) before opening a PR. Use [Conventional Commits](https://www.conventionalcommits.org/) (`cz commit`).
166
+
167
+ ## License
168
+
169
+ [MIT](./LICENSE) © OSGeo Nepal contributors.
osmsg-1.0.2/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # osmsg
2
+
3
+ [![CI](https://github.com/osgeonepal/osmsg/actions/workflows/ci.yml/badge.svg)](https://github.com/osgeonepal/osmsg/actions/workflows/ci.yml)
4
+ [![Docker](https://github.com/osgeonepal/osmsg/actions/workflows/docker.yml/badge.svg)](https://github.com/osgeonepal/osmsg/actions/workflows/docker.yml)
5
+ [![PyPI](https://img.shields.io/pypi/v/osmsg.svg)](https://pypi.org/project/osmsg/)
6
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
7
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
8
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
10
+ [![Container](https://img.shields.io/badge/ghcr.io-osgeonepal%2Fosmsg-2496ED?logo=docker)](https://github.com/osgeonepal/osmsg/pkgs/container/osmsg)
11
+
12
+ **OpenStreetMap Stats Generator.** A tiny CLI (and Python library) that turns OSM history into per-user counts of nodes, ways, and relations created, modified, or deleted, written to parquet, csv, json, markdown, or Postgres.
13
+
14
+ A Project of [OSGeo Nepal](https://osgeonepal.org).
15
+
16
+ ## What you get
17
+
18
+ - Per-user create/modify/delete counts over any time window.
19
+ - Tag and hashtag breakdowns (e.g. `building`, `#hotosm`).
20
+ - Country and custom-boundary filters via Geofabrik.
21
+ - Cron-friendly resume with `--update`.
22
+ - Outputs you can query: parquet, csv, json, markdown, DuckDB, Postgres.
23
+
24
+ ## Install
25
+
26
+ Pick the one that fits how you work.
27
+
28
+ ```bash
29
+ pip install osmsg # into your project
30
+ uv tool install osmsg # standalone CLI
31
+ docker run --rm -v "$PWD:/work" -w /work ghcr.io/osgeonepal/osmsg:latest --last hour
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ ```bash
37
+ osmsg --last hour # planet, last hour
38
+ osmsg --last day --tags building # last day with a tag breakdown
39
+ osmsg --hashtags hotosm --last day # only changesets tagged #hotosm
40
+ ```
41
+
42
+ That's it. A `stats.duckdb` and a `stats.parquet` show up in your current folder.
43
+
44
+ ## Tutorials
45
+
46
+ ### 1. Stats for a country
47
+
48
+ ```bash
49
+ osmsg --country nepal --last day
50
+ ```
51
+
52
+ `--country` resolves through Geofabrik and needs an OSM account. Set `OSM_USERNAME` and `OSM_PASSWORD` in your shell or a `.env` file:
53
+
54
+ ```bash
55
+ export OSM_USERNAME=you
56
+ export OSM_PASSWORD=secret
57
+ ```
58
+
59
+ ### 2. A custom date range with summaries
60
+
61
+ ```bash
62
+ osmsg --start "2026-04-01" --end "2026-04-08" \
63
+ --tags building --tags highway --summary
64
+ ```
65
+
66
+ `--summary` adds a daily rollup file alongside the per-changeset stats.
67
+
68
+ ### 3. Run on a schedule
69
+
70
+ ```bash
71
+ osmsg --country nepal --update # picks up where the last run stopped
72
+ ```
73
+
74
+ Drop that into cron or a GitHub Actions schedule. State is stored inside the DuckDB file, so reruns are safe.
75
+
76
+ ### 4. Query the output
77
+
78
+ ```bash
79
+ duckdb stats.duckdb -c "SELECT username, SUM(nodes_created) AS n
80
+ FROM users JOIN changeset_stats USING (uid)
81
+ GROUP BY username ORDER BY n DESC LIMIT 10"
82
+ ```
83
+
84
+ Same schema in DuckDB and Postgres: `users`, `changesets`, `changeset_stats`, `state`.
85
+
86
+ ### 5. Use it as a library
87
+
88
+ ```python
89
+ from datetime import datetime, UTC
90
+ from osmsg import RunConfig, run
91
+
92
+ result = run(RunConfig(
93
+ name="nepal",
94
+ countries=["nepal"],
95
+ start_date=datetime(2026, 4, 25, tzinfo=UTC),
96
+ end_date=datetime(2026, 4, 26, tzinfo=UTC),
97
+ ))
98
+ print(result["files"]["parquet"])
99
+ ```
100
+
101
+ Same pipeline as the CLI.
102
+
103
+ ### 6. Long flag lists? Use a config
104
+
105
+ ```bash
106
+ osmsg --config nepal.yaml
107
+ ```
108
+
109
+ Any flag works as a YAML key. See [docs/Manual.md](./docs/Manual.md) for the full list.
110
+
111
+ ## Output formats
112
+
113
+ Every run writes `stats.duckdb` (or `<--name>.duckdb`) plus the formats you ask for via `-f parquet|csv|json|markdown|psql`. Parquet is the default. Open it with duckdb, polars, pandas, anything.
114
+
115
+ ## Documentation
116
+
117
+ - [Installation](./docs/Installation.md)
118
+ - [Manual](./docs/Manual.md) (every flag, with examples)
119
+ - [Version control / release notes](./docs/Version_control.md)
120
+
121
+ ## Contributing
122
+
123
+ Pull requests are welcome. Quick path:
124
+
125
+ ```bash
126
+ git clone https://github.com/osgeonepal/osmsg && cd osmsg
127
+ git switch develop
128
+ uv sync
129
+ uv run pre-commit install
130
+ uv run pytest -m "not network"
131
+ ```
132
+
133
+ Please read [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md) before opening a PR. Use [Conventional Commits](https://www.conventionalcommits.org/) (`cz commit`).
134
+
135
+ ## License
136
+
137
+ [MIT](./LICENSE) © OSGeo Nepal contributors.
@@ -0,0 +1,95 @@
1
+ """OpenStreetMap stats generator. Parquet-first, OAuth 2.0, UTC-only.
2
+
3
+ Library usage::
4
+
5
+ from osmsg import RunConfig, run, OsmsgError
6
+
7
+ cfg = RunConfig(
8
+ name="nepal",
9
+ countries=["nepal"],
10
+ start_date=datetime(2026, 4, 25, tzinfo=UTC),
11
+ end_date=datetime(2026, 4, 26, tzinfo=UTC),
12
+ formats=["parquet"],
13
+ osm_username="...",
14
+ osm_password="...",
15
+ )
16
+ try:
17
+ result = run(cfg)
18
+ except OsmsgError as exc:
19
+ ...
20
+ print(result["files"]["parquet"]) # → 'nepal.parquet'
21
+
22
+ CLI entry point: ``osmsg`` (defined in ``osmsg.cli``).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from .__version__ import __version__
28
+ from .db import (
29
+ attach_metadata,
30
+ attach_tag_stats,
31
+ connect,
32
+ create_tables,
33
+ daily_summary,
34
+ get_state,
35
+ upsert_state,
36
+ user_stats,
37
+ )
38
+ from .exceptions import (
39
+ CredentialsRequiredError,
40
+ GeofabrikAuthError,
41
+ NoDataFoundError,
42
+ OsmsgError,
43
+ UnknownRegionError,
44
+ )
45
+ from .export import (
46
+ summary_markdown,
47
+ table_markdown,
48
+ to_csv,
49
+ to_json,
50
+ to_parquet,
51
+ to_psql,
52
+ )
53
+ from .geofabrik import country_update_url, load_index
54
+ from .models import (
55
+ Action,
56
+ Changeset,
57
+ ChangesetStats,
58
+ ElementStat,
59
+ TagValueStat,
60
+ User,
61
+ )
62
+ from .pipeline import RunConfig, run
63
+
64
+ __all__ = [
65
+ "Action",
66
+ "Changeset",
67
+ "ChangesetStats",
68
+ "CredentialsRequiredError",
69
+ "ElementStat",
70
+ "GeofabrikAuthError",
71
+ "NoDataFoundError",
72
+ "OsmsgError",
73
+ "RunConfig",
74
+ "TagValueStat",
75
+ "UnknownRegionError",
76
+ "User",
77
+ "__version__",
78
+ "attach_metadata",
79
+ "attach_tag_stats",
80
+ "connect",
81
+ "country_update_url",
82
+ "create_tables",
83
+ "daily_summary",
84
+ "get_state",
85
+ "load_index",
86
+ "run",
87
+ "summary_markdown",
88
+ "table_markdown",
89
+ "to_csv",
90
+ "to_json",
91
+ "to_parquet",
92
+ "to_psql",
93
+ "upsert_state",
94
+ "user_stats",
95
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "1.0.2"
@@ -0,0 +1,41 @@
1
+ """Shared `requests.Session` with retry policy + connect/read timeouts.
2
+
3
+ Every HTTP call in osmsg goes through this session so retry behaviour and
4
+ timeout defaults are consistent. Per-request `timeout=` still wins.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import requests
10
+ from requests.adapters import HTTPAdapter
11
+ from urllib3.util.retry import Retry
12
+
13
+ USER_AGENT = "osmsg"
14
+ DEFAULT_TIMEOUT = (10, 60) # (connect, read) seconds
15
+
16
+
17
+ class _TimeoutSession(requests.Session):
18
+ """Session that applies `DEFAULT_TIMEOUT` whenever the caller did not specify one."""
19
+
20
+ def request(self, method, url, *args, **kwargs):
21
+ kwargs.setdefault("timeout", DEFAULT_TIMEOUT)
22
+ return super().request(method, url, *args, **kwargs)
23
+
24
+
25
+ def make_session() -> requests.Session:
26
+ """Fresh session with the standard timeout + retry policy (use when a flow needs its own cookie jar)."""
27
+ s = _TimeoutSession()
28
+ retry = Retry(
29
+ total=5,
30
+ backoff_factor=0.5,
31
+ status_forcelist=(429, 500, 502, 503, 504),
32
+ allowed_methods=frozenset({"GET", "POST", "HEAD"}),
33
+ )
34
+ adapter = HTTPAdapter(max_retries=retry, pool_maxsize=32)
35
+ s.mount("https://", adapter)
36
+ s.mount("http://", adapter)
37
+ s.headers["User-Agent"] = USER_AGENT
38
+ return s
39
+
40
+
41
+ session = make_session()
@@ -0,0 +1,118 @@
1
+ """OAuth 2.0 cookie client for Geofabrik internal download server.
2
+
3
+ Mirrors https://github.com/geofabrik/sendfile_osm_oauth_protector
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import urllib.parse
9
+ from html.parser import HTMLParser
10
+
11
+ from ._http import make_session
12
+ from ._http import session as shared_session
13
+ from .exceptions import GeofabrikAuthError
14
+
15
+ DEFAULT_OSM_HOST = "https://www.openstreetmap.org"
16
+ DEFAULT_CONSUMER_URL = "https://osm-internal.download.geofabrik.de/get_cookie"
17
+
18
+
19
+ class _CsrfFinder(HTMLParser):
20
+ def __init__(self) -> None:
21
+ super().__init__()
22
+ self.token: str | None = None
23
+
24
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
25
+ if tag != "meta" or self.token is not None:
26
+ return
27
+ a = dict(attrs)
28
+ if a.get("name") == "csrf-token":
29
+ content = a.get("content")
30
+ if content:
31
+ self.token = content
32
+
33
+
34
+ def _csrf(html: str) -> str:
35
+ parser = _CsrfFinder()
36
+ parser.feed(html)
37
+ if parser.token is None:
38
+ raise GeofabrikAuthError("authenticity_token not found in OSM response")
39
+ return parser.token
40
+
41
+
42
+ def get_geofabrik_cookie(
43
+ username: str,
44
+ password: str,
45
+ osm_host: str = DEFAULT_OSM_HOST,
46
+ consumer_url: str = DEFAULT_CONSUMER_URL,
47
+ ) -> str:
48
+ if not username or not password:
49
+ raise GeofabrikAuthError("OSM username and password required")
50
+
51
+ r = shared_session.post(f"{consumer_url}?action=get_authorization_url", timeout=30)
52
+ if r.status_code != 200:
53
+ raise GeofabrikAuthError(f"get_authorization_url returned HTTP {r.status_code}")
54
+ payload = r.json()
55
+ try:
56
+ authz_url = payload["authorization_url"]
57
+ state = payload["state"]
58
+ redirect_uri = payload["redirect_uri"]
59
+ client_id = payload["client_id"]
60
+ except KeyError as exc:
61
+ raise GeofabrikAuthError(f"missing field in authorization response: {exc}") from exc
62
+
63
+ s = make_session()
64
+
65
+ r = s.get(f"{osm_host}/login?cookie_test=true", timeout=30)
66
+ if r.status_code != 200:
67
+ raise GeofabrikAuthError(f"GET /login returned HTTP {r.status_code}")
68
+
69
+ r = s.post(
70
+ f"{osm_host}/login",
71
+ data={
72
+ "username": username,
73
+ "password": password,
74
+ "referer": "/",
75
+ "commit": "Login",
76
+ "authenticity_token": _csrf(r.text),
77
+ },
78
+ allow_redirects=False,
79
+ timeout=30,
80
+ )
81
+ if r.status_code != 302:
82
+ raise GeofabrikAuthError(f"OSM login failed (HTTP {r.status_code}); check credentials")
83
+
84
+ r = s.get(authz_url, allow_redirects=False, timeout=30)
85
+ if r.status_code != 302:
86
+ if r.status_code != 200:
87
+ raise GeofabrikAuthError(f"GET authorize returned HTTP {r.status_code}")
88
+ r = s.post(
89
+ authz_url,
90
+ data={
91
+ "client_id": client_id,
92
+ "redirect_uri": redirect_uri,
93
+ "authenticity_token": _csrf(r.text),
94
+ "state": state,
95
+ "response_type": "code",
96
+ "scope": "read_prefs",
97
+ "nonce": "",
98
+ "code_challenge": "",
99
+ "code_challenge_method": "",
100
+ "commit": "Authorize",
101
+ },
102
+ allow_redirects=False,
103
+ timeout=30,
104
+ )
105
+ if r.status_code != 302:
106
+ raise GeofabrikAuthError(f"POST authorize returned HTTP {r.status_code}")
107
+
108
+ location = r.headers.get("location") or ""
109
+ if "?" not in location:
110
+ raise GeofabrikAuthError("authorization redirect missing query string")
111
+
112
+ s.get(f"{osm_host}/logout", timeout=30)
113
+
114
+ final_url = f"{location}&{urllib.parse.urlencode({'format': 'http'})}"
115
+ r = shared_session.get(final_url, timeout=30)
116
+ if r.status_code != 200 or not r.text.strip():
117
+ raise GeofabrikAuthError(f"cookie exchange failed (HTTP {r.status_code})")
118
+ return r.text.strip()
@@ -0,0 +1,37 @@
1
+ """Geometry helpers: boundary parsing + bbox centroid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from shapely.geometry import MultiPolygon, Polygon, box, shape
10
+ from shapely.geometry.base import BaseGeometry
11
+
12
+
13
+ def load_boundary(input_data: str) -> BaseGeometry:
14
+ """Accept either inline GeoJSON text or a path to a GeoJSON file."""
15
+ try:
16
+ payload: Any = json.loads(input_data)
17
+ except json.JSONDecodeError as exc:
18
+ path = Path(input_data)
19
+ if not path.is_file():
20
+ raise ValueError(f"Not valid JSON or a file path: {input_data!r}") from exc
21
+ payload = json.loads(path.read_text())
22
+
23
+ geometry = payload.get("geometry") if "geometry" in payload else payload
24
+ if not geometry or geometry.get("type") not in ("Polygon", "MultiPolygon"):
25
+ raise ValueError("Boundary must be a Polygon or MultiPolygon GeoJSON.")
26
+ geom = shape(geometry)
27
+ if isinstance(geom, (Polygon, MultiPolygon)):
28
+ return geom
29
+ raise ValueError(f"Unexpected geometry type: {type(geom).__name__}")
30
+
31
+
32
+ def bbox_centroid(bounds) -> tuple[float, float] | None:
33
+ """Centroid of an osmium bounding box, or None if invalid."""
34
+ if not bounds.valid():
35
+ return None
36
+ geom = box(bounds.bottom_left.lon, bounds.bottom_left.lat, bounds.top_right.lon, bounds.top_right.lat)
37
+ return geom.centroid.x, geom.centroid.y