@@ -136,16 +136,18 @@ def _compose_cmd():
136136 except Exception : return 'docker' , 'compose'
137137
138138def _clean_cfg ():
139- 'Create a docker config dir with credential helpers stripped'
139+ 'Create a docker config dir with credential helpers stripped, symlinking contexts and cli-plugins '
140140 src = Path (os .environ .get ('DOCKER_CONFIG' , Path .home ()/ '.docker' ))
141141 dst = Path .home ()/ '.fastops' / 'config'
142142 cfgf = dst / 'config.json'
143- if cfgf .exists (): return str (dst )
144- cfg = src .joinpath ('config.json' ).read_json () if (src / 'config.json' ).exists () else {}
145- cfg .pop ('credsStore' , None ); cfg .pop ('credHelpers' , None )
146- cfgf .write_json (cfg )
147- ctx_src , ctx_dst = src / 'contexts' , dst / 'contexts'
148- if ctx_src .exists () and not ctx_dst .exists (): ctx_dst .symlink_to (ctx_src )
143+ if not cfgf .exists ():
144+ dst .mkdir (parents = True , exist_ok = True )
145+ cfg = src .joinpath ('config.json' ).read_json () if (src / 'config.json' ).exists () else {}
146+ cfg .pop ('credsStore' , None ); cfg .pop ('credHelpers' , None )
147+ cfgf .write_json (cfg )
148+ for name in ('contexts' , 'cli-plugins' ):
149+ link , target = dst / name , src / name
150+ if target .exists () and not link .exists (): link .symlink_to (target )
149151 return str (dst )
150152
151153class Docker (Cli ):
@@ -166,10 +168,20 @@ def compose(self, *a, f='docker-compose.yml'):
166168
167169# %% ../nbs/00_core.ipynb #ae10280a7aae03af
168170@patch
169- def build (df :Dockerfile , tag :str = None , path :str = '.' , rm = True , no_creds = False , fn = 'Dockerfile' ):
170- 'Build image from Dockerfile. path is the build context directory.'
171+ def build (df :Dockerfile , tag :str = None , path :str = '.' , no_creds = False , fn = 'Dockerfile' ):
172+ 'Build image from Dockerfile via docker compose build (uses daemon BuildKit, no buildx required).'
173+ import subprocess , tempfile
171174 df .save (Path (path ) / fn )
172- Docker (no_creds = no_creds ).build (str (path ).rstrip ('/' )+ '/' , t = tag , rm = rm )
175+ svc = {'build' : {'context' : str (Path (path ).resolve ())}}
176+ if tag : svc ['image' ] = tag
177+ with tempfile .NamedTemporaryFile (mode = 'w' , suffix = '.yml' , delete = False ) as f :
178+ f .write (yaml .dump ({'services' : {'img' : svc }})); tmp = f .name
179+ try :
180+ pre = ['--config' , _clean_cfg ()] if no_creds else []
181+ res = subprocess .run (['docker' ] + pre + ['compose' , '-f' , tmp , 'build' ], capture_output = True )
182+ if res .returncode : raise IOError ((res .stdout + b' ;; ' + res .stderr ).decode ().strip ())
183+ finally :
184+ Path (tmp ).unlink (missing_ok = True )
173185 return tag
174186
175187def test (img_or_tag :str , cmd ):
@@ -261,11 +273,11 @@ def __repr__(self): return str(self)
261273
262274# %% ../nbs/00_core.ipynb #f56e4e9f534ac2ca
263275@patch
264- def inst_uv (self :Dockerfile ):
265- 'Single-stage uv install: copy uv binary and sync deps'
266- return ( self .copy ('/uv' , '/usr/local/bin/uv' , from_ = 'ghcr.io/astral-sh/uv:latest' )
267- .copy ('pyproject.toml ' , '.' ).copy ('uv.lock' , '. ' )
268- . run_mount ( ' uv sync --frozen -- no-dev', target = '/root/.cache/uv' ) )
276+ def inst_uv (self :Dockerfile , req = False , wd = '/app' ):
277+ 'Single-stage uv install: copy uv binary and sync deps. req=True uses requirements.txt instead of pyproject.toml '
278+ self = self .copy ('/uv' , '/usr/local/bin/uv' , from_ = 'ghcr.io/astral-sh/uv:latest' )
279+ if req : return self .copy ('requirements.txt ' , '.' ).run ('uv pip install --system -r requirements.txt ' )
280+ return self . copy ( 'pyproject.toml' , '.' ). run ( ' uv sync --no-dev'). env ( 'PATH' , f' { wd } /.venv/bin:$PATH' )
269281
270282@patch
271283def with_uv (self :Dockerfile , uv_image , image , workdir ):
@@ -284,11 +296,11 @@ def packages(self:Dockerfile, *pkgs):
284296
285297# %% ../nbs/00_core.ipynb #e17b41b26d006536
286298def python_app (port = 8000 , cmd = None , im = 'python:3.13-slim' , wd = '/app' , pkgs = None ,
287- vols = None , multistage = True , uv_image = 'ghcr.io/astral-sh/uv:python3.13-bookworm' , healthcheck = None ):
288- 'Python app Dockerfile. multistage=True (default): uv-builder → slim. False: single-stage with uv binary copy.'
299+ vols = None , multistage = True , uv_image = 'ghcr.io/astral-sh/uv:python3.13-bookworm' , healthcheck = None , req = False ):
300+ 'Python app Dockerfile. multistage=True (default): uv-builder → slim. False: single-stage with uv binary copy. req=True: use requirements.txt instead of pyproject.toml (forces multistage=False). '
289301 if multistage :
290- df = Dockerfile ().with_uv (uv_image , im , wd ).packages ( * listify ( pkgs )). copy (wd , wd , from_ = 'builder' ).env ('PATH' , f'{ wd } /.venv/bin:$PATH' )
291- else : df = Dockerfile ().from_ (im ).workdir (wd ).packages (* listify (pkgs )).inst_uv ().copy ('.' , '.' )
302+ df = Dockerfile ().with_uv (uv_image , im , wd ).copy (wd , wd , from_ = 'builder' ).env ('PATH' , f'{ wd } /.venv/bin:$PATH' ). packages ( * listify ( pkgs ) )
303+ else : df = Dockerfile ().from_ (im ).workdir (wd ).packages (* listify (pkgs )).inst_uv (req = req , wd = wd ).copy ('.' , '.' )
292304 if vols : df = df .run ('mkdir -p ' + ' ' .join (listify (vols )))
293305 if healthcheck : df = df .healthcheck (f'curl -f http://localhost:{ port } { healthcheck } ' , i = '30s' , t = '5s' , r = '3' )
294306 _cmd = cmd or ['python' , 'main.py' ]
@@ -301,8 +313,8 @@ def fastapi_react(port=8000, node_version='20', frontend_dir='frontend', build_d
301313 df = (Dockerfile ().from_ (f'node:{ node_version } -slim' , as_ = 'frontend' )
302314 .workdir ('/build' ).copy (f'{ frontend_dir } /package*.json' , '.' )
303315 .run ('npm ci' ).copy (frontend_dir , '.' ).run ('npm run build' ))
304- if multistage : df = df .with_uv (uv_image , image , '/app' ).packages ( * listify ( pkgs )). copy ('/app' , '/app' , from_ = 'builder' ).env ('PATH' , '/app/.venv/bin:$PATH' )
305- else : df = df .from_ (image ).workdir ('/app' ).packages (* listify (pkgs )).inst_uv ().copy ('.' , '.' )
316+ if multistage : df = df .with_uv (uv_image , image , '/app' ).copy ('/app' , '/app' , from_ = 'builder' ).env ('PATH' , '/app/.venv/bin:$PATH' ). packages ( * listify ( pkgs ) )
317+ else : df = df .from_ (image ).workdir ('/app' ).packages (* listify (pkgs )).inst_uv (wd = '/app' ).copy ('.' , '.' )
306318 df = df .copy (f'/build/{ build_dir } ' , '/app/static' , from_ = 'frontend' )
307319 if healthcheck : df = df .healthcheck (f'curl -f http://localhost:{ port } { healthcheck } ' , i = '30s' , t = '5s' , r = '3' )
308320 return df .expose (port ).cmd (['uvicorn' , 'main:app' , '--host' , '0.0.0.0' , f'--port={ port } ' ])
@@ -335,7 +347,7 @@ def node_app(port=3000, node_version='20', cmd=None, build_cmd='npm run build',
335347 .expose (port ).cmd (cmd or ['serve' , '-s' , '.' , '-l' , str (port )]))
336348 return df .expose (port ).cmd (cmd or ['node' , 'index.js' ])
337349
338- fasthtml_app = bind (python_app , port = 5001 , cmd = ['python' , 'app.py' ])
350+ fasthtml_app = bind (python_app , port = 5001 , cmd = ['python' , 'app.py' ], multistage = False )
339351
340352def detect_app (path = '.' , multistage = True , ** kw ):
341353 'A naive project type detector from path and return the appropriate Dockerfile. for **kw lookup other app builders'
@@ -349,7 +361,8 @@ def detect_app(path='.', multistage=True, **kw):
349361 if has ('package.json' ) and has_py : return fastapi_react (multistage = multistage , ** kw )
350362 if has ('package.json' ): return node_app (** kw )
351363 if pyp and 'python-fasthtml' in read ('pyproject.toml' ): return fasthtml_app (multistage = multistage , ** kw )
352- if has_py : return python_app (multistage = multistage , ** kw )
364+ if pyp : return python_app (multistage = multistage , ** kw )
365+ if req : return python_app (multistage = False , req = True , ** kw )
353366 raise ValueError (f'Cannot detect project type in { path !r} ' )
354367
355368# %% ../nbs/00_core.ipynb #5f5a4229dae3d2c4
0 commit comments