1+ #!/usr/bin/env python3
2+ # =============================================================================
3+ # publish-local.py — Build oshconnect and publish to the local PyPI server.
4+ #
5+ # One-command dev loop: edit code -> run this -> downstream picks up the new
6+ # version via `pip install --index-url http://localhost:8090/simple/ oshconnect`.
7+ #
8+ # The local pypiserver container must be running (started automatically below
9+ # via `docker compose up -d pypi` if it isn't). pypiserver is configured with
10+ # `-o` so re-uploading the same version overwrites — no version bump needed.
11+ #
12+ # Usage:
13+ # ./scripts/publish-local.py # build + upload
14+ # ./scripts/publish-local.py --no-build # upload existing wheel(s) in dist/
15+ # LOCAL_PYPI_URL=http://host:port ./scripts/publish-local.py # override URL
16+ # =============================================================================
17+ from __future__ import annotations
18+
19+ import argparse
20+ import os
21+ import shutil
22+ import subprocess
23+ import sys
24+ import time
25+ import urllib .error
26+ import urllib .request
27+ from pathlib import Path
28+
29+ PROJECT_ROOT = Path (__file__ ).resolve ().parent .parent
30+ PYPI_URL = os .environ .get ("LOCAL_PYPI_URL" , "http://localhost:8090" )
31+
32+ CYAN = "\033 [0;36m"
33+ GREEN = "\033 [0;32m"
34+ RED = "\033 [0;31m"
35+ NC = "\033 [0m"
36+
37+
38+ def info (msg : str ) -> None :
39+ print (f"{ CYAN } [INFO]{ NC } { msg } " )
40+
41+
42+ def ok (msg : str ) -> None :
43+ print (f"{ GREEN } [OK]{ NC } { msg } " )
44+
45+
46+ def fail (msg : str , code : int = 1 ) -> None :
47+ print (f"{ RED } [FAIL]{ NC } { msg } " , file = sys .stderr )
48+ sys .exit (code )
49+
50+
51+ def pypi_ready (url : str ) -> bool :
52+ """Return True iff the URL responds with a 2xx or 3xx status."""
53+ try :
54+ with urllib .request .urlopen (url , timeout = 3 ) as resp :
55+ return 200 <= resp .status < 400
56+ except (urllib .error .URLError , urllib .error .HTTPError , TimeoutError , OSError ):
57+ return False
58+
59+
60+ def ensure_pypi (url : str ) -> None :
61+ info (f"Checking local PyPI at { url } " )
62+ if pypi_ready (url ):
63+ ok ("Local PyPI is already running" )
64+ return
65+
66+ info ("Local PyPI not running — starting container..." )
67+ res = subprocess .run (
68+ ["docker" , "compose" , "up" , "-d" , "pypi" ], cwd = PROJECT_ROOT
69+ )
70+ if res .returncode != 0 :
71+ fail ("docker compose up failed" )
72+
73+ for i in range (1 , 11 ):
74+ time .sleep (1 )
75+ if pypi_ready (url ):
76+ ok ("Local PyPI started" )
77+ return
78+ info (f" waiting... ({ i } /10)" )
79+
80+ fail ("Could not start local PyPI" )
81+
82+
83+ def build_wheel () -> None :
84+ info ("Building wheel..." )
85+ for sub in ("dist" , "build" ):
86+ shutil .rmtree (PROJECT_ROOT / sub , ignore_errors = True )
87+ for egg in (PROJECT_ROOT / "src" ).glob ("*.egg-info" ):
88+ shutil .rmtree (egg , ignore_errors = True )
89+
90+ res = subprocess .run (["uv" , "build" ], cwd = PROJECT_ROOT )
91+ if res .returncode != 0 :
92+ fail ("uv build failed" )
93+
94+
95+ def find_wheels () -> list [Path ]:
96+ return sorted ((PROJECT_ROOT / "dist" ).glob ("*.whl" ))
97+
98+
99+ def publish (url : str , wheels : list [Path ]) -> None :
100+ # pypiserver runs with `-a . -P .` (auth disabled), but `uv publish`/
101+ # pypiserver still issue a Basic-Auth challenge that triggers an
102+ # interactive prompt. Pass empty credentials to satisfy it.
103+ info (f"Uploading to { url } " )
104+ cmd = [
105+ "uv" , "publish" ,
106+ "--publish-url" , url ,
107+ "--username" , "" ,
108+ "--password" , "" ,
109+ * [str (w ) for w in wheels ],
110+ ]
111+ res = subprocess .run (cmd , cwd = PROJECT_ROOT )
112+ if res .returncode != 0 :
113+ fail ("uv publish failed" )
114+
115+
116+ def main () -> int :
117+ parser = argparse .ArgumentParser (
118+ description = "Build oshconnect and publish it to the local PyPI server." ,
119+ )
120+ parser .add_argument (
121+ "--no-build" ,
122+ action = "store_true" ,
123+ help = "Skip wheel build; upload whatever is in dist/." ,
124+ )
125+ args = parser .parse_args ()
126+
127+ info (f"Project root: { PROJECT_ROOT } " )
128+ ensure_pypi (PYPI_URL )
129+
130+ if not args .no_build :
131+ build_wheel ()
132+
133+ wheels = find_wheels ()
134+ if not wheels :
135+ fail (
136+ f"No wheel found in { PROJECT_ROOT } /dist/. "
137+ "Build first or remove --no-build."
138+ )
139+
140+ ok (f"Wheel(s): { ' ' .join (str (w .relative_to (PROJECT_ROOT )) for w in wheels )} " )
141+ publish (PYPI_URL , wheels )
142+
143+ ok ("Published to local PyPI" )
144+ print ()
145+ print (f" Browse: { PYPI_URL } /simple/" )
146+ print (f" Install: pip install --index-url { PYPI_URL } /simple/ oshconnect" )
147+ print (f" uv: uv pip install --index-url { PYPI_URL } /simple/ oshconnect" )
148+ print (f" uv sync: uv sync (if pyproject.toml has [[tool.uv.index]] configured)" )
149+ return 0
150+
151+
152+ if __name__ == "__main__" :
153+ sys .exit (main ())
0 commit comments