From d3e6d7ac42f450bf2baf783b17dc26dac27090f0 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Fri, 27 Feb 2026 14:27:38 +0000 Subject: [PATCH] Reorganise --- AGENTS.md | 47 ++++---- README.md | 16 +-- pyproject.toml | 29 +++++ src/osm_geojson/__init__.py | 1 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 247 bytes src/osm_geojson/pt/__init__.py | 1 + .../pt/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 230 bytes .../pt/__pycache__/cli.cpython-313.pyc | Bin 0 -> 6105 bytes .../pt/__pycache__/core.cpython-313.pyc | Bin 0 -> 7526 bytes src/osm_geojson/pt/cli.py | 111 ++++++++++++++++++ osm-pt-geojson => src/osm_geojson/pt/core.py | 104 +--------------- tests/test_osm_pt_geojson.py | 75 +++++------- 12 files changed, 206 insertions(+), 178 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/osm_geojson/__init__.py create mode 100644 src/osm_geojson/__pycache__/__init__.cpython-313.pyc create mode 100644 src/osm_geojson/pt/__init__.py create mode 100644 src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc create mode 100644 src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc create mode 100644 src/osm_geojson/pt/__pycache__/core.cpython-313.pyc create mode 100644 src/osm_geojson/pt/cli.py rename osm-pt-geojson => src/osm_geojson/pt/core.py (59%) mode change 100755 => 100644 diff --git a/AGENTS.md b/AGENTS.md index 25c4c71..11dbc37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,20 +11,31 @@ Requests for HTTP calls. ## Repository layout ``` -osm-pt-geojson - Fetch a public transport route relation, list stops, export GeoJSON -README.md - User-facing documentation -AGENTS.md - This file +pyproject.toml - Package metadata and build configuration +src/ + osm_geojson/ + __init__.py - Top-level package marker + pt/ + __init__.py - Public transport subpackage + core.py - Data fetching and processing functions + cli.py - Click CLI commands +tests/ + fixtures/ - Saved OSM API responses for offline testing + test_osm_pt_geojson.py - Test suite +README.md - User-facing documentation +AGENTS.md - This file ``` -New tools are added as individual scripts at the repo root. +`osm_geojson` is the top-level namespace for all tools in this collection. Each +tool lives in its own subpackage (e.g. `osm_geojson.pt`), with its CLI entry +point registered in `pyproject.toml` under `[project.scripts]`. ## Code conventions -- Python 3, shebang `#!/usr/bin/python3` +- Python 3.11+ - CLI via [Click](https://click.palletsprojects.com/) - HTTP via [Requests](https://requests.readthedocs.io/) - Parse XML with lxml if needed; prefer the OSM JSON API where possible -- Scripts have no `.py` extension and are executable - Errors go to stderr; data output goes to stdout - GeoJSON output uses `ensure_ascii=False` - All modules, functions, and test functions must have docstrings @@ -42,10 +53,10 @@ No authentication is required for read-only access. Include a descriptive ## Type checking -All scripts use type hints. Run mypy in strict mode to check: +All code uses type hints. Run mypy in strict mode to check: ``` -mypy --strict osm-pt-geojson +mypy --strict src/osm_geojson/ ``` ## Testing @@ -59,21 +70,6 @@ pytest tests/ Tests use the `responses` library to mock HTTP calls and never hit the live OSM API. Fixture data is stored in `tests/fixtures/` as saved API responses. -Because tool scripts have hyphens in their names and no `.py` extension, they -cannot be imported with the normal `importlib.util.spec_from_file_location`. -Use `importlib.machinery.SourceFileLoader` instead: - -```python -import importlib.machinery -import importlib.util - -_loader = importlib.machinery.SourceFileLoader("osm_pt_geojson", "osm-pt-geojson") -_spec = importlib.util.spec_from_loader("osm_pt_geojson", _loader) -assert _spec -osm = importlib.util.module_from_spec(_spec) -_loader.exec_module(osm) -``` - ### Example relations used during development | ID | Description | @@ -83,8 +79,9 @@ _loader.exec_module(osm) ## Dependencies -Install with pip: +Use a virtual environment. Install the package and test dependencies with: ``` -pip install click requests +python3 -m venv .venv +.venv/bin/pip install -e ".[dev]" ``` diff --git a/README.md b/README.md index 8c87f1a..063030f 100644 --- a/README.md +++ b/README.md @@ -54,15 +54,17 @@ A GeoJSON `FeatureCollection` containing: - A `Point` feature for each stop, with a `name` property (omitted with `--no-stops`). -#### Requirements +#### Installation -- Python 3 -- [Click](https://click.palletsprojects.com/) -- [Requests](https://requests.readthedocs.io/) - -Install dependencies: ``` -pip install click requests +pip install osm-geojson +``` + +Or from source: +``` +git clone https://git.4angle.com/edward/openstreetmap-tools +cd openstreetmap-tools +pip install -e . ``` ## Licence diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..45ed064 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "osm-geojson" +version = "0.1.0" +description = "Fetch OSM public transport route relations and export as GeoJSON" +license = {text = "MIT"} +authors = [{name = "Edward Betts"}] +requires-python = ">=3.11" +dependencies = [ + "click", + "requests", +] + +[project.optional-dependencies] +dev = [ + "mypy", + "pytest", + "responses", + "types-requests", +] + +[project.scripts] +osm-pt-geojson = "osm_geojson.pt.cli:cli" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/osm_geojson/__init__.py b/src/osm_geojson/__init__.py new file mode 100644 index 0000000..34f6cf9 --- /dev/null +++ b/src/osm_geojson/__init__.py @@ -0,0 +1 @@ +"""Fetch OSM public transport route relations and export as GeoJSON.""" diff --git a/src/osm_geojson/__pycache__/__init__.cpython-313.pyc b/src/osm_geojson/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a380201ef97862631d78c2f3f91b98a8d82ef91e GIT binary patch literal 247 zcmey&%ge<81aXxMGd+OxV-N=h7@>^M96-iYhG2#whIB?vrYc9b)RN>31^-}Qg@V$g zoXli}lA^@C;)4955{07t(vnn#qSTzklFaFrQ_>~NwLH68o)6dAyP1R3LDNig)(JwAa);BURGSkm5&ea7Qs#}t;o1U7V zRh*v(mIX@0!}#&>nR%Hd@$q^EmA5!-a`RJ4b5iY!*nq}@{7?*X{|9D9M#ftV?nNv> F4gi$)M?3%k literal 0 HcmV?d00001 diff --git a/src/osm_geojson/pt/__init__.py b/src/osm_geojson/pt/__init__.py new file mode 100644 index 0000000..195b8e9 --- /dev/null +++ b/src/osm_geojson/pt/__init__.py @@ -0,0 +1 @@ +"""Public transport route tools for osm_geojson.""" diff --git a/src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78cb62468b6289abd59c21dd9c559413e0892016 GIT binary patch literal 230 zcmYk0K?;IE6ozLiN(ApPTNgS}(JI=viJ${KOs6u`CvQfg?$H5SdL_4AAn*c>QqaG7 z|Ig$9_*>6&nUz~V(}lgSF8sv5rgf}&ustJo;{M literal 0 HcmV?d00001 diff --git a/src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2fb996dbb9b27d55f5f15e1769c411aea14c9b2b GIT binary patch literal 6105 zcmcIoTW}NC89sZFw7T1t!N%sYfI}^eZ3;GEV+aH=V8BVe-o%64C_-AxBJ!^0>ndrnKrbGZk%SYQH&e?i-nQraa0qq|V~WWEx)b&>2x@hBiPZ@V1~9VW@*b| z4qEOEKj6TQ0Vj42xUg$Lz`}qVy9YejGvLKu5lQ^0LvoDzB(~j!kmQuuE~n&bLKk`L zALWKN+GJPSZK&HCNSh^rthC2CI2s(c<@Rig_*>u|UO~l?*qSisC zTh$UuREEM4~t8(f<|UCd`^XMb)!diN^GJDrGu}c2vV;F6SIsW32r^m@x%K zjcbXNnjFkIU&9*E9G+oJPQf_rG%6yb^7!Yjx+Q<@R9jx3 ze(e)~_gw9w&wuOWjgwQy-#hn&LxFvd1ph71P0!S;`7`-L#ZY+KSP;7Gk6jOh#wTuA z^}yf#jZQe5sXMxt`*^SCCB_wpVWofu%N9%+zKxPhn9c5j?#t3SF_XOjViFA;Q}v97 z4UsY|{^RHGS7}yDcMQ%TEW(~;_(ymXR3-;}<89C*Bb})8U4*wnlk=xjy3wv1)LV7a zTXEb}0`$onZ)KD?xs+XqZaDZ~{uO)$7pu(4$aVsiiI5evjMUh!kOh~@Y|orfoe{zt zEknd%R~2vxMs^OlSAB+|!~zY=v53pq^uWoUcOn-dLZ?OM?R^0V?RzsKug)?sbxoP31AZJi3jMjP)(w*1?1b3|H*>4ZSwe1ThCm} zLfhGSzIF1%RAb@z67Rk?aCKm+Az%Od#@{v;c1*V|Y}i+9++PeHxX<@grauVfM}D^{ zA1vTvaOZu#ZKFnXZ%`9ZScO_yC-Rd&q&#@)b-$B^QHp;+3EZ z(-j=~D|puHq1Wrzz!FClmb_y7B*Bu2km{VM&sj^TQs)d{qMa&S0AP%Z&_IR|_`_Y6;@*1pSdNDi*TIM$|c?uXW$?B^L8s<-#8 zw7`qk)`5s*?*-5&ND=PRfD|UuV6Oy9UP4Lok-9SSlacJMSV7HGJLuZVj`C>rj@k%8 zDq3Xsa6OzX$Tf1U%u9hDms%GAeOR+~GH2T(;4T*q=8jm7X6IQXi8jU+D^BZ5k|epJ z<-VeESrZAuCMqc1fp@^!m?(Sjsh~@^3c>&Kdr%!_Z=+!j)O85d#mLMs`;@u}MAI@1 zIW5^}i*v|w4QrY<_6tZx&zkKWvCIJo$&3yL)=~~Oke%dC579iFGD<_i>Eog{Mu!wH zCSvd&fQ*pxDz?UBy3(G4_@L@3BXvm$(;SrMnS)`L#sSlj)-Ed;5Qq#kZLzn zRwg((Sq6Fc7-XRoUd*zXqn3C(`qElFmevnlY74IV-_LGWO4JCuyNe`SKVenCe_dsR(Xsoebb@&MRBtW3GE=hWZW;!9U*Dyq3 z1yd~@C)-buil*=@N(0nHR&cIvjvTQ#2wEQ(xv0a0aUXgzi}bBWAfBz{`FJ21^>3m z{xAH&$^J!GaNgChCg{J)50-^`m$0%Jcir+#6l+ zpP%f16lhu$>ZisFj)E~ee)m#w- z^%sIjD^7#YkRdUB`o^oW^s<>#F_wQW!SJR)3R!s+Dll>18YWKD(JBL93N+>WX1nh- z&Ig7jPcHFwQ~PZ-0esTX5#C_BqS1sFk47 zU(mEPb!^HxeVDoaM_o6r*3v~n0nU@r+_`ahvAaSX$JhITzf-iOHZCGtJt1%`cs zpn6QI(t3f}kUv?1{)XA@bNoj;Y4>jZ*W^oSJz`zgny)thY|}E!F#DG|#0E>GR}v8G zxYqn`^Y!QgvuTlWUfXelg!tkpFc459j|lI@eX~SLT^9vW!fn3&&?)C?5XtJo9>qZDU#% zYwJrKv|rS%D>cfs&hyJSZ3{c}dTQoStPr^vG?a45^c}-b}h8 zsVqi(Wces#GcvlsG}+fJi^9Iu_r0)_X~`ceA!y(QW6U(s?wmoJd;Bt)SbZsamFC9+ IOi^?AFV|uXng9R* literal 0 HcmV?d00001 diff --git a/src/osm_geojson/pt/__pycache__/core.cpython-313.pyc b/src/osm_geojson/pt/__pycache__/core.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1434f58b2923b3ea33668debdb662e9845428451 GIT binary patch literal 7526 zcmbVQT~HfWmcFfSNiDU0K!{%hZZHrO8_7R7iE(TX#$e1CNN8kZn^+^H2FnpG(%l3@ zvWaGQt5$Mm#`4UhA}UiAc`FZiXC9c^eQ@&R%+7=7O^Omn>3FxOnt5^6KFDQPc4qfs z_uSi(5FV$pwXM?a+xO?(bMHOpJKw#J?RG1IXJz$P&?r>o<@u8~LJ3U-lhe z4#7C<6in4+{fuC4L01{MMqsPUOD$`m%sFe9l^3QqXfl(2QkXrR2s1gJ;*;iJ_P*^5LKw z?3)+W(3Oa^zz3x;za+;(qN3o>`DH1jMq-k}&&OmwrY!a?seKD#?5YxzybtjXPk1Pe z9+Xzsy056}k}}})t*oqgV@sl>sIn-ki@_ytOkVH>mmM^6>O9)By_s+oVr$JA{|3}Z= zNZ=%Tke>Khf6r6%=oq~l%~Kxd+VlvvA6~s8g2hCm{G>3>5597Smqld>j0Z}=dV=t& zZ~Z<#B88&MVPbI5d{JDS6J>rSxT+8Xlwx5~@vd2YY902mSge&KYbf)R+Qz3co!xf-h*__ zR;nAFo_>X2tK%ykQPzR?ST!?DEC3VJSXq2ySyWU-qZdR~tC`Y2hp&aiC2ZW9F%*r2 zUe_2gbS0+I%Blj{YY|oB6g8+WD}fM9QDg9QJVqItuSPG+QH{YXrcomH3}rd0Di}U^ z{Ak0zE3rkYCM2Vd~v`O7dlu}_VaJ(JWzMWE!Vz9rQc0$=wot!i~- zVuA9k0(e>FHW&rtwel$>ar7sp;F~g<*W@9XCeK9^%G%!Z@183unx39mQ9@^NK zed%o0GoC$vA?Li9V2UhaZC{xibLPhMp}e{M0c*cuy>3m-X1c!WJecb|nC&=}Jv4In zt-Fiay6No2%b&A>2P}7kz0Rg;f5H{oIzGCve&NGQ@sR>o8z2Ak6DP8{zv8-bT-Re{ zfPqW$&Zl2Z52cP}!k@EyEB$Y9N$$UkMrih5N+YQMv!;_Y`W@{aa?-zR^bT3+f3+GR zuQ3?tLKVz_mUwLm()&%{+Ykq6Lu?uWafKCm{;QAFz^G;~27!?-*l)%^3)g^FmEL}4|oTH&P z#ta?))@n*vQ!e1wT0j#OP;wc4Beg13Z#m!8P2xVK{|C@W&1yjC*U z02n&7ZU--nqoNgc^~8q@on7%$h0dP%=vIw8b@7WDKEV`hHK~T=*#uSKY$?-wM+W?)NShcJE2KQ{kH}d0Q(kdj6+@j5`y))w1!{mmLFz#$DG(A30HN zN704Y8i1=3vSjPKGp}b}%N{(NtsBn@=Rap>D){mfZmXp=K3Ztr6+gY@YDpjY!quHH z0Z^rSlE<@cV|i}u-kaHTvwwIe8(hdwE);C;8ev}I00xtA&g+<2QwIgXzzg4U#8wie$-p4!=B|sUBIEX(L^w8 zbExf7?797npXvwx1bmUb$^f&djN+$(Ss{U`_V2Qv6Wj#0#bDgb5!}SBjg@*p&uV?f z!TbPq%vcu2Rs1ICiIIhw!k7^O@#>i}E&)5%AB@(5nW^AZ zmDC`SN;HC2+fu=6s@B3(?g1JQy|CE>ddGM4-kujYz;^FyJ<4**@2BK#KtH^<XLMXQ4H(<;sXbk8x^Bmp^fx~Gj zZ^L50ry|z>AAZ>MBnZjLdJNvzIvCZQz$OglgRn z6|4;(*Wt~ERmNfo9QMvFN21{XA!xw)U|7Zbax@kSMwR2<>Xv6ggo0_bUr9cu&@LCLU4(n6M<}tc-475 zbZ9iJE+j$KW725Y2#vwh(o7KS0P%5LdJ|}}7~YNx9`-4ekza-}fz;v+B@@>SD z$j5fc|EL<_U4K1kc@$a=!Cx5xl!rNfYeTlWg0nVZDKZA*iC+mtAbKZ0p8n0ATNnNl zXP>aBsdfGAM&oAf?UsCNf4=c>e0a;%obE_>{$g*!^tHV?-IKR>BnN2B1v2 zl1I{p^nrC_o@*=EYHyrPo=v;bgXxp$hWj?YV0WbsCNHEr(^KovlP|USRa2%jBV^_> z&*ht+zi-=%n;l5bK*y8o4SDldTx<;<&@sq|{j-Ftm_yUpNwecs*EW7o&B%?CDnHk-1I19zFvZNnAa?%P@)wV}H9M?5-l zipq1-pNgBxJ@K=*^R|NtfAV75n6tNMjJK>ChjaV^DEvgsano65`Y)ez(+Z{pzi+af z8eo3k?SfQ30R{38Akl2q7PlirU}8wa|MA~K0@pGWfIDo7{s@LL07=X?Q28sR01urA zErS6{Mw~7!%>1Yi079Vqzzk;I*v=CmWM}?<0JsK$5g-(P76OD(qQ}8$3Av?#iZN?1 zR{3RwurdIRYW;ah48n0p~?fbAT9Zx%AUx-zr(cO6VR6IAl$#HoV47It$mXKTy2Z?$Yvu$wvC0m5W{I#8%@ zNQ^*oeFR8A>mgu3HdlOH_jOqAoyb?&zkh@p9N?!ay9WiON1&>RFXgA6*`~6q$Ee;? zzc3t9Aap#tJzchqs`hZb!B>T}r-=dVuW{{kX@7(KmF2~t)F;ESJU9nYKngCFZw4@6 z4(J8IQTG9>p65vhz_^6bUcxDXR6{QrjuS}U0}0?TW~^|;R%OWJ<`C`p-bc2KC)J1VS{cZmXDEYiuNZEr5>*nhEFBn8r%60H(=Gs6soS6K2N5 z3$bs!4-u+#1f#&iK&WJy`T0@U*C5o6;{fczkxa5~d#c|mkmv?)a!~c7!&n|}#lhjJ zl-Eo2bmr6k%8oR)tKc)&mfAE-+L$XWONjPL< zxsn48kPd|$a3g)ssen&m~J@uCFsjpnpeTRIm++VPHje^}<@elBz zZJG-a+wXu~Jy60;r&RNu^L6-ZO1oO4@2by$=P#J6sA5fdRJhpOG3Gef4PITr={E~C zUPFnD(UMiN10Abfn>>lR2p{B6i?K0ba)KWb;VMcNhhovFNN#z7`;gnKU^KeQD{z4Y zC*V0zT@gizS65;>Hv=jch8o3N1+Dtwv?Ncno{dPNpvpi=z$H7tf;6s**2!>YN5q3Q zhPa9W+{8$n2=2lvmGT^J7Q#sw5)Z9QV&P6}QG}DOW`%p|B~ex*qM{jJiD6<>`&Uyc zE5Od{lT|znL2enYUl4%~8AF1nmV^fxJ2YW<@nG%Iu48AUw@9t!bRx9vV zL;wUhAG^`+j`(QNg!*5IPkiHSOwa|+0dR#mkgYa3mY^OuIx-!Z;aj~MgB#-Q(amVy zF$AsHhOgMR9NU&w(pT=Y&pqN$O;fRkoP&2|Oga8wfvd}Nt%cp)srf=f=f?cz8@I1! z8;&HnultUE(R}`&F5jBYyn5%_mnY6;UpbdMaqhz&NY;DO2R`h%eL3OCHJ?v7wi-GU zT%q|n$T7BmnEdhNmbhcEu z1L>xena(#nUufR*QTuxPhB@E7{}E@}<#=SaTC7iKo6+=bE8Gx%)7YInofu50r6c!N zYj?r3|8Cda<iF5egUp|SGaY!`d5a#}H2N?;eGTZ>dRnDZcIE_Pw6*9y6#gS&l zt_gR-@*wVsj{}-fU0#Zc(MKha|vQq_HnA#=^@{@wogJ ze1hE|<+qR)X^Nu0M#JAA>%XCfKO);V$hl=b5I2K0S?oYKxwx@lbtIJ3P~uwB8#e=O zN$p7~sovz}xOGQO(PX1s8DQ=3+L+$lv#D(M-oE? None: + """Write GeoJSON to a file, or to stdout if output_path is None.""" + text = json.dumps(geojson, ensure_ascii=False, indent=2) + if output_path: + with open(output_path, "w", encoding="utf-8") as f: + f.write(text) + click.echo(f"Wrote {output_path}", err=True) + else: + click.echo(text) + + +@click.group() +def cli() -> None: + """OSM public transport route → GeoJSON tool.""" + + +@cli.command("list-stations") +@click.argument("relation_id", type=int) +def list_stations(relation_id: int) -> None: + """List all stations in an OSM public transport route relation.""" + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + click.echo(f"Route: {tags.get('name', relation_id)}") + click.echo(f"Stops ({len(stop_ids)}):") + for i, sid in enumerate(stop_ids, 1): + if sid in nodes: + click.echo(f" {i:2}. {node_name(nodes[sid])}") + else: + click.echo(f" {i:2}. (node {sid} not in response)") + + +@cli.command("route-between") +@click.argument("relation_id", type=int) +@click.argument("from_station") +@click.argument("to_station") +@click.option("--output", "-o", type=click.Path(), default=None, help="Output file (default: stdout)") +@click.option("--no-stops", is_flag=True, default=False, help="Omit stop points from output.") +def route_between( + relation_id: int, + from_station: str, + to_station: str, + output: str | None, + no_stops: bool, +) -> None: + """Output GeoJSON for the route segment between two named stations.""" + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + route_coords = build_route_coords(way_ids, ways, nodes) + + def find_stop(name: str) -> int | None: + """Return the node ID of the stop matching name (case-insensitive), or None.""" + for sid in stop_ids: + if sid in nodes and node_name(nodes[sid]).lower() == name.lower(): + return sid + return None + + sid_from = find_stop(from_station) + sid_to = find_stop(to_station) + + errors = [] + if sid_from is None: + errors.append(f"Station not found: {from_station!r}") + if sid_to is None: + errors.append(f"Station not found: {to_station!r}") + if errors: + for e in errors: + click.echo(f"Error: {e}", err=True) + click.echo("Available stations:", err=True) + for sid in stop_ids: + if sid in nodes: + click.echo(f" {node_name(nodes[sid])}", err=True) + sys.exit(1) + + assert sid_from is not None and sid_to is not None + idx_from = nearest_coord_index(nodes[sid_from]["lon"], nodes[sid_from]["lat"], route_coords) + idx_to = nearest_coord_index(nodes[sid_to]["lon"], nodes[sid_to]["lat"], route_coords) + + geojson = make_geojson( + route_coords, stop_ids, nodes, tags, idx_from=idx_from, idx_to=idx_to, no_stops=no_stops + ) + output_geojson(geojson, output) + + +@cli.command("full-route") +@click.argument("relation_id", type=int) +@click.option("--output", "-o", type=click.Path(), default=None, help="Output file (default: stdout)") +@click.option("--no-stops", is_flag=True, default=False, help="Omit stop points from output.") +def full_route(relation_id: int, output: str | None, no_stops: bool) -> None: + """Output GeoJSON for the entire route, end to end.""" + data = fetch_relation_full(relation_id) + nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) + route_coords = build_route_coords(way_ids, ways, nodes) + geojson = make_geojson(route_coords, stop_ids, nodes, tags, no_stops=no_stops) + output_geojson(geojson, output) diff --git a/osm-pt-geojson b/src/osm_geojson/pt/core.py old mode 100755 new mode 100644 similarity index 59% rename from osm-pt-geojson rename to src/osm_geojson/pt/core.py index a58a11b..c49d524 --- a/osm-pt-geojson +++ b/src/osm_geojson/pt/core.py @@ -1,6 +1,4 @@ -#!/usr/bin/python3 -"""Fetch an OSM public transport route relation and export it as GeoJSON.""" -import json +"""Core data-fetching and processing functions for osm-pt-geojson.""" import sys from typing import Any @@ -76,6 +74,7 @@ def build_route_coords( return [] def way_node_ids(way_id: int) -> list[int]: + """Return the ordered node IDs for a way, or an empty list if not found.""" return ways[way_id]["nodes"] if way_id in ways else [] chain: list[int] = list(way_node_ids(way_ids[0])) @@ -173,102 +172,3 @@ def make_geojson( ) return {"type": "FeatureCollection", "features": features} - - -def output_geojson(geojson: GeoJson, output_path: str | None) -> None: - """Write GeoJSON to a file, or to stdout if output_path is None.""" - text = json.dumps(geojson, ensure_ascii=False, indent=2) - if output_path: - with open(output_path, "w", encoding="utf-8") as f: - f.write(text) - click.echo(f"Wrote {output_path}", err=True) - else: - click.echo(text) - - -@click.group() -def cli() -> None: - """OSM public transport route → GeoJSON tool.""" - - -@cli.command("list-stations") -@click.argument("relation_id", type=int) -def list_stations(relation_id: int) -> None: - """List all stations in an OSM public transport route relation.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) - click.echo(f"Route: {tags.get('name', relation_id)}") - click.echo(f"Stops ({len(stop_ids)}):") - for i, sid in enumerate(stop_ids, 1): - if sid in nodes: - click.echo(f" {i:2}. {node_name(nodes[sid])}") - else: - click.echo(f" {i:2}. (node {sid} not in response)") - - -@cli.command("route-between") -@click.argument("relation_id", type=int) -@click.argument("from_station") -@click.argument("to_station") -@click.option("--output", "-o", type=click.Path(), default=None, help="Output file (default: stdout)") -@click.option("--no-stops", is_flag=True, default=False, help="Omit stop points from output.") -def route_between( - relation_id: int, - from_station: str, - to_station: str, - output: str | None, - no_stops: bool, -) -> None: - """Output GeoJSON for the route segment between two named stations.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) - route_coords = build_route_coords(way_ids, ways, nodes) - - def find_stop(name: str) -> int | None: - for sid in stop_ids: - if sid in nodes and node_name(nodes[sid]).lower() == name.lower(): - return sid - return None - - sid_from = find_stop(from_station) - sid_to = find_stop(to_station) - - errors = [] - if sid_from is None: - errors.append(f"Station not found: {from_station!r}") - if sid_to is None: - errors.append(f"Station not found: {to_station!r}") - if errors: - for e in errors: - click.echo(f"Error: {e}", err=True) - click.echo("Available stations:", err=True) - for sid in stop_ids: - if sid in nodes: - click.echo(f" {node_name(nodes[sid])}", err=True) - sys.exit(1) - - assert sid_from is not None and sid_to is not None - idx_from = nearest_coord_index(nodes[sid_from]["lon"], nodes[sid_from]["lat"], route_coords) - idx_to = nearest_coord_index(nodes[sid_to]["lon"], nodes[sid_to]["lat"], route_coords) - - geojson = make_geojson( - route_coords, stop_ids, nodes, tags, idx_from=idx_from, idx_to=idx_to, no_stops=no_stops - ) - output_geojson(geojson, output) - - -@cli.command("full-route") -@click.argument("relation_id", type=int) -@click.option("--output", "-o", type=click.Path(), default=None, help="Output file (default: stdout)") -@click.option("--no-stops", is_flag=True, default=False, help="Omit stop points from output.") -def full_route(relation_id: int, output: str | None, no_stops: bool) -> None: - """Output GeoJSON for the entire route, end to end.""" - data = fetch_relation_full(relation_id) - nodes, ways, stop_ids, way_ids, tags = parse_elements(data, relation_id) - route_coords = build_route_coords(way_ids, ways, nodes) - geojson = make_geojson(route_coords, stop_ids, nodes, tags, no_stops=no_stops) - output_geojson(geojson, output) - - -if __name__ == "__main__": - cli() diff --git a/tests/test_osm_pt_geojson.py b/tests/test_osm_pt_geojson.py index 521139e..c89e236 100644 --- a/tests/test_osm_pt_geojson.py +++ b/tests/test_osm_pt_geojson.py @@ -6,20 +6,8 @@ import pytest import responses as responses_lib from click.testing import CliRunner -import sys -sys.path.insert(0, str(Path(__file__).parent.parent)) - -# Import the script as a module. The filename has hyphens so we use importlib. -import importlib.machinery -import importlib.util - -_loader = importlib.machinery.SourceFileLoader( - "osm_pt_geojson", str(Path(__file__).parent.parent / "osm-pt-geojson") -) -_spec = importlib.util.spec_from_loader("osm_pt_geojson", _loader) -assert _spec -osm = importlib.util.module_from_spec(_spec) -_loader.exec_module(osm) +from osm_geojson.pt import core +from osm_geojson.pt.cli import cli, output_geojson FIXTURES = Path(__file__).parent / "fixtures" FULL_URL = "https://www.openstreetmap.org/api/0.6/relation/15083963/full.json" @@ -35,7 +23,7 @@ def full_data() -> dict: @pytest.fixture() def parsed(full_data: dict) -> tuple: """Return parsed elements (nodes, ways, stop_ids, way_ids, tags) for relation 15083963.""" - return osm.parse_elements(full_data, RELATION_ID) + return core.parse_elements(full_data, RELATION_ID) # --------------------------------------------------------------------------- @@ -51,8 +39,8 @@ def test_parse_elements_stop_count(parsed: tuple) -> None: def test_parse_elements_first_and_last_stop(parsed: tuple) -> None: """The first and last stops match the route terminus names.""" nodes, ways, stop_ids, way_ids, tags = parsed - assert osm.node_name(nodes[stop_ids[0]]) == "Arnavutköy Hastane" - assert osm.node_name(nodes[stop_ids[-1]]) == "Gayrettepe" + assert core.node_name(nodes[stop_ids[0]]) == "Arnavutköy Hastane" + assert core.node_name(nodes[stop_ids[-1]]) == "Gayrettepe" def test_parse_elements_way_count(parsed: tuple) -> None: @@ -70,9 +58,8 @@ def test_parse_elements_tags(parsed: tuple) -> None: def test_parse_elements_unknown_relation(full_data: dict) -> None: """Requesting a relation ID not present in the response exits with an error.""" - runner = CliRunner() with pytest.raises(SystemExit): - osm.parse_elements(full_data, 9999999) + core.parse_elements(full_data, 9999999) # --------------------------------------------------------------------------- @@ -82,7 +69,7 @@ def test_parse_elements_unknown_relation(full_data: dict) -> None: def test_build_route_coords_returns_coords(parsed: tuple) -> None: """Chained coordinates are non-empty and fall within the Istanbul bounding box.""" nodes, ways, stop_ids, way_ids, tags = parsed - coords = osm.build_route_coords(way_ids, ways, nodes) + coords = core.build_route_coords(way_ids, ways, nodes) assert len(coords) > 0 for coord in coords: assert len(coord) == 2 @@ -93,7 +80,7 @@ def test_build_route_coords_returns_coords(parsed: tuple) -> None: def test_build_route_coords_empty_ways() -> None: """An empty way list returns an empty coordinate list.""" - assert osm.build_route_coords([], {}, {}) == [] + assert core.build_route_coords([], {}, {}) == [] # --------------------------------------------------------------------------- @@ -103,13 +90,13 @@ def test_build_route_coords_empty_ways() -> None: def test_nearest_coord_index_exact() -> None: """Returns the index of an exact coordinate match.""" coords = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]] - assert osm.nearest_coord_index(3.0, 4.0, coords) == 1 + assert core.nearest_coord_index(3.0, 4.0, coords) == 1 def test_nearest_coord_index_approximate() -> None: """Returns the index of the closest coordinate when there is no exact match.""" coords = [[0.0, 0.0], [10.0, 0.0], [20.0, 0.0]] - assert osm.nearest_coord_index(9.0, 0.0, coords) == 1 + assert core.nearest_coord_index(9.0, 0.0, coords) == 1 # --------------------------------------------------------------------------- @@ -119,25 +106,25 @@ def test_nearest_coord_index_approximate() -> None: def test_node_name_uses_name_tag() -> None: """Prefers the name tag when present.""" node = {"id": 1, "lat": 0.0, "lon": 0.0, "tags": {"name": "Central", "ref": "C1"}} - assert osm.node_name(node) == "Central" + assert core.node_name(node) == "Central" def test_node_name_falls_back_to_ref() -> None: """Falls back to the ref tag when there is no name tag.""" node = {"id": 1, "lat": 0.0, "lon": 0.0, "tags": {"ref": "C1"}} - assert osm.node_name(node) == "C1" + assert core.node_name(node) == "C1" def test_node_name_falls_back_to_id() -> None: """Falls back to the node ID when tags are present but empty.""" node = {"id": 42, "lat": 0.0, "lon": 0.0, "tags": {}} - assert osm.node_name(node) == "42" + assert core.node_name(node) == "42" def test_node_name_no_tags() -> None: """Falls back to the node ID when the tags key is absent.""" node = {"id": 99, "lat": 0.0, "lon": 0.0} - assert osm.node_name(node) == "99" + assert core.node_name(node) == "99" # --------------------------------------------------------------------------- @@ -147,8 +134,8 @@ def test_node_name_no_tags() -> None: def test_make_geojson_full(parsed: tuple) -> None: """Full output contains one LineString and one Point per stop.""" nodes, ways, stop_ids, way_ids, tags = parsed - coords = osm.build_route_coords(way_ids, ways, nodes) - geojson = osm.make_geojson(coords, stop_ids, nodes, tags) + coords = core.build_route_coords(way_ids, ways, nodes) + geojson = core.make_geojson(coords, stop_ids, nodes, tags) assert geojson["type"] == "FeatureCollection" features = geojson["features"] @@ -162,8 +149,8 @@ def test_make_geojson_full(parsed: tuple) -> None: def test_make_geojson_no_stops(parsed: tuple) -> None: """With no_stops=True, only the LineString feature is included.""" nodes, ways, stop_ids, way_ids, tags = parsed - coords = osm.build_route_coords(way_ids, ways, nodes) - geojson = osm.make_geojson(coords, stop_ids, nodes, tags, no_stops=True) + coords = core.build_route_coords(way_ids, ways, nodes) + geojson = core.make_geojson(coords, stop_ids, nodes, tags, no_stops=True) features = geojson["features"] assert all(f["geometry"]["type"] == "LineString" for f in features) @@ -172,11 +159,11 @@ def test_make_geojson_no_stops(parsed: tuple) -> None: def test_make_geojson_slice(parsed: tuple) -> None: """Slicing by coord index produces a shorter LineString with the correct length.""" nodes, ways, stop_ids, way_ids, tags = parsed - coords = osm.build_route_coords(way_ids, ways, nodes) - full = osm.make_geojson(coords, stop_ids, nodes, tags) + coords = core.build_route_coords(way_ids, ways, nodes) + full = core.make_geojson(coords, stop_ids, nodes, tags) full_line_len = len(full["features"][0]["geometry"]["coordinates"]) - sliced = osm.make_geojson(coords, stop_ids, nodes, tags, idx_from=10, idx_to=50) + sliced = core.make_geojson(coords, stop_ids, nodes, tags, idx_from=10, idx_to=50) sliced_line_len = len(sliced["features"][0]["geometry"]["coordinates"]) assert sliced_line_len == 41 # 50 - 10 + 1 @@ -186,8 +173,8 @@ def test_make_geojson_slice(parsed: tuple) -> None: def test_make_geojson_linestring_properties(parsed: tuple) -> None: """The LineString feature carries route properties from the OSM relation tags.""" nodes, ways, stop_ids, way_ids, tags = parsed - coords = osm.build_route_coords(way_ids, ways, nodes) - geojson = osm.make_geojson(coords, stop_ids, nodes, tags) + coords = core.build_route_coords(way_ids, ways, nodes) + geojson = core.make_geojson(coords, stop_ids, nodes, tags) props = geojson["features"][0]["properties"] assert props["ref"] == "M11" @@ -203,7 +190,7 @@ def test_cli_list_stations(full_data: dict) -> None: """list-stations prints the route name and all stop names.""" responses_lib.add(responses_lib.GET, FULL_URL, json=full_data) runner = CliRunner() - result = runner.invoke(osm.cli, ["list-stations", str(RELATION_ID)]) + result = runner.invoke(cli, ["list-stations", str(RELATION_ID)]) assert result.exit_code == 0 assert "M11" in result.output assert "Arnavutköy Hastane" in result.output @@ -215,7 +202,7 @@ def test_cli_list_stations_http_error() -> None: """list-stations exits with code 1 on an HTTP error response.""" responses_lib.add(responses_lib.GET, FULL_URL, status=503) runner = CliRunner() - result = runner.invoke(osm.cli, ["list-stations", str(RELATION_ID)]) + result = runner.invoke(cli, ["list-stations", str(RELATION_ID)]) assert result.exit_code == 1 @@ -228,7 +215,7 @@ def test_cli_full_route_geojson(full_data: dict) -> None: """full-route outputs a valid GeoJSON FeatureCollection to stdout.""" responses_lib.add(responses_lib.GET, FULL_URL, json=full_data) runner = CliRunner() - result = runner.invoke(osm.cli, ["full-route", str(RELATION_ID)]) + result = runner.invoke(cli, ["full-route", str(RELATION_ID)]) assert result.exit_code == 0 geojson = json.loads(result.output) assert geojson["type"] == "FeatureCollection" @@ -239,7 +226,7 @@ def test_cli_full_route_no_stops(full_data: dict) -> None: """full-route --no-stops omits Point features from the output.""" responses_lib.add(responses_lib.GET, FULL_URL, json=full_data) runner = CliRunner() - result = runner.invoke(osm.cli, ["full-route", str(RELATION_ID), "--no-stops"]) + result = runner.invoke(cli, ["full-route", str(RELATION_ID), "--no-stops"]) assert result.exit_code == 0 geojson = json.loads(result.output) types = [f["geometry"]["type"] for f in geojson["features"]] @@ -252,7 +239,7 @@ def test_cli_full_route_output_file(full_data: dict, tmp_path) -> None: responses_lib.add(responses_lib.GET, FULL_URL, json=full_data) out = tmp_path / "route.geojson" runner = CliRunner() - result = runner.invoke(osm.cli, ["full-route", str(RELATION_ID), "-o", str(out)]) + result = runner.invoke(cli, ["full-route", str(RELATION_ID), "-o", str(out)]) assert result.exit_code == 0 assert out.exists() geojson = json.loads(out.read_text()) @@ -269,7 +256,7 @@ def test_cli_route_between(full_data: dict) -> None: responses_lib.add(responses_lib.GET, FULL_URL, json=full_data) runner = CliRunner() result = runner.invoke( - osm.cli, + cli, ["route-between", str(RELATION_ID), "Arnavutköy Hastane", "Gayrettepe"], ) assert result.exit_code == 0 @@ -289,7 +276,7 @@ def test_cli_route_between_unknown_station(full_data: dict) -> None: responses_lib.add(responses_lib.GET, FULL_URL, json=full_data) runner = CliRunner() result = runner.invoke( - osm.cli, + cli, ["route-between", str(RELATION_ID), "Nonexistent", "Gayrettepe"], ) assert result.exit_code == 1 @@ -301,7 +288,7 @@ def test_cli_route_between_stops_subset(full_data: dict) -> None: responses_lib.add(responses_lib.GET, FULL_URL, json=full_data) runner = CliRunner() result = runner.invoke( - osm.cli, + cli, ["route-between", str(RELATION_ID), "İstanbul Havalimanı", "Hasdal"], ) assert result.exit_code == 0