@@ -2150,16 +2150,33 @@ def normalize_service(service: dict[str, Any], sub_dir: str = "") -> dict[str, A
21502150
21512151 new_volumes .append (v )
21522152 service ["volumes" ] = new_volumes
2153+ if "env_file" in service and sub_dir :
2154+ new_env_file = []
2155+ for ef in service ["env_file" ]:
2156+ if isinstance (ef , str ):
2157+ if is_relative_ref (ef ):
2158+ ef = os .path .join (sub_dir , ef )
2159+ elif isinstance (ef , dict ):
2160+ path = ef .get ("path" )
2161+ if isinstance (path , str ) and is_relative_ref (path ):
2162+ ef ["path" ] = os .path .join (sub_dir , path )
2163+ new_env_file .append (ef )
2164+ service ["env_file" ] = new_env_file
21532165 return service
21542166
21552167
2156- def normalize (compose : dict [str , Any ]) -> dict [str , Any ]:
2168+ def normalize (compose : dict [str , Any ], sub_dir : str = "" ) -> dict [str , Any ]:
21572169 """
21582170 convert compose dict of some keys from string or dicts into arrays
2171+
2172+ If ``sub_dir`` is provided, relative paths in ``volumes``, ``env_file`` and
2173+ ``build.context`` are rewritten to be relative to ``sub_dir`` (used when an
2174+ included file lives in a different directory than the project root, per
2175+ Compose Spec resolution of paths in ``include:``d files).
21592176 """
21602177 services = compose .get ("services" , {}) or {}
21612178 for service in services .values ():
2162- normalize_service (service )
2179+ normalize_service (service , sub_dir )
21632180 return compose
21642181
21652182
@@ -2721,6 +2738,11 @@ def _parse_compose_file(self) -> None:
27212738 compose : dict [str , Any ] = {}
27222739 # Iterate over files primitively to allow appending to files in-loop
27232740 files_iter = iter (files )
2741+ # Track files appended by ``include:`` so we can resolve their
2742+ # relative paths against the included file's directory per the
2743+ # Compose Spec, without changing the legacy merge behavior of
2744+ # files passed directly via ``-f``.
2745+ include_origin_files : set [str ] = set ()
27242746
27252747 while True :
27262748 try :
@@ -2737,7 +2759,22 @@ def _parse_compose_file(self) -> None:
27372759 if not isinstance (content , dict ):
27382760 log .fatal ("Compose file does not contain a top level object: %s" , filename )
27392761 sys .exit (1 )
2740- content = normalize (content )
2762+ # For files arriving via ``include:``, paths inside the file must
2763+ # resolve against the included file's directory rather than the
2764+ # project root (Compose Spec, ``include`` section). Pass that as
2765+ # sub_dir so volumes / env_file / build.context get rewritten.
2766+ file_sub_dir = ""
2767+ if filename in include_origin_files :
2768+ file_dir = os .path .dirname (os .path .abspath (filename ))
2769+ file_sub_dir = os .path .relpath (file_dir , self .dirname )
2770+ if file_sub_dir == "." :
2771+ file_sub_dir = ""
2772+ elif not file_sub_dir .startswith (("." , "/" )):
2773+ # Prefix with "./" so rewritten paths remain recognizable
2774+ # as relative refs (is_relative_ref checks for "./"/".."
2775+ # prefixes).
2776+ file_sub_dir = "./" + file_sub_dir
2777+ content = normalize (content , file_sub_dir )
27412778 # log(filename, json.dumps(content, indent = 2))
27422779
27432780 # See also https://docs.docker.com/compose/how-tos/project-name/#set-a-project-name
@@ -2780,23 +2817,28 @@ def _parse_compose_file(self) -> None:
27802817 if not isinstance (include , list ):
27812818 raise RuntimeError ("`include` must be a list" )
27822819
2820+ new_includes : list [str ] = []
27832821 for item in include :
27842822 if isinstance (item , str ):
2785- files .append (os .path .join (os .path .dirname (filename ), item ))
2823+ new_includes .append (os .path .join (os .path .dirname (filename ), item ))
27862824 elif isinstance (item , dict ):
27872825 if "path" not in item :
27882826 raise RuntimeError ("Missing required 'path' key in `include` block" )
27892827 path = item ["path" ]
27902828 if isinstance (path , str ):
2791- files .append (os .path .join (os .path .dirname (filename ), path ))
2829+ new_includes .append (os .path .join (os .path .dirname (filename ), path ))
27922830 elif isinstance (path , list ):
2793- files .extend ([os .path .join (os .path .dirname (filename ), p ) for p in path ])
2831+ new_includes .extend (
2832+ os .path .join (os .path .dirname (filename ), p ) for p in path
2833+ )
27942834 else :
27952835 raise RuntimeError ("'path' must be a string or a list of strings" )
27962836 else :
27972837 raise RuntimeError (
27982838 "Items in `include` must be strings or dictionaries with a 'path' key"
27992839 )
2840+ files .extend (new_includes )
2841+ include_origin_files .update (new_includes )
28002842 # As compose obj is updated and tested with every loop, not deleting `include`
28012843 # from it, results in it being tested again and again, original values for
28022844 # `include` be appended to `files`, and, included files be processed for ever.
0 commit comments