1+ from builtins import open as builtin_open
12import importlib
23import os
34import sys
4- import copy
55from urllib .parse import urlparse
66from contextlib import contextmanager , _GeneratorContextManager as GCM
7+ import threading
78
9+ from funcy import wrap_with
810import ruamel .yaml
911from voluptuous import Schema , Required , Invalid
1012
1113from dvc .repo import Repo
12- from dvc .exceptions import DvcException , FileMissingError
14+ from dvc .exceptions import DvcException
1315from dvc .external_repo import external_repo
1416
1517
16- SUMMON_SCHEMA = Schema (
18+ SUMMON_FILE_SCHEMA = Schema (
1719 {
1820 Required ("objects" ): [
1921 {
2022 Required ("name" ): str ,
2123 "meta" : dict ,
2224 Required ("summon" ): {
23- Required ("type" ): "python" ,
24- Required ("call" ): str ,
25- "args" : dict ,
25+ Required ("type" ): str ,
2626 "deps" : [str ],
27+ str : object ,
2728 },
2829 }
2930 ]
3031 }
3132)
33+ SUMMON_PYTHON_SCHEMA = Schema (
34+ {
35+ Required ("type" ): "python" ,
36+ Required ("call" ): str ,
37+ "args" : dict ,
38+ "deps" : [str ],
39+ }
40+ )
3241
3342
3443class SummonError (DvcException ):
@@ -97,32 +106,74 @@ def _make_repo(repo_url, rev=None):
97106
98107def summon (name , repo = None , rev = None , summon_file = "dvcsummon.yaml" , args = None ):
99108 """Instantiate an object described in the summon file."""
109+ with prepare_summon (
110+ name , repo = repo , rev = rev , summon_file = summon_file
111+ ) as desc :
112+ try :
113+ summon_dict = SUMMON_PYTHON_SCHEMA (desc .obj ["summon" ])
114+ except Invalid as exc :
115+ raise SummonError (str (exc )) from exc
116+
117+ _args = {** summon_dict .get ("args" , {}), ** (args or {})}
118+ return _invoke_method (summon_dict ["call" ], _args , desc .repo .root_dir )
119+
120+
121+ @contextmanager
122+ def prepare_summon (name , repo = None , rev = None , summon_file = "dvcsummon.yaml" ):
123+ """Does a couple of things every summon needs as a prerequisite:
124+ clones the repo, parses the summon file and pulls the deps.
125+
126+ Calling code is expected to complete the summon logic following
127+ instructions stated in "summon" dict of the object spec.
128+
129+ Returns a SummonDesc instance, which contains references to a Repo object,
130+ named object specification and resolved paths to deps.
131+ """
100132 with _make_repo (repo , rev = rev ) as _repo :
101133 try :
102134 path = os .path .join (_repo .root_dir , summon_file )
103- obj = _get_object_from_summon_file (name , path )
104- info = obj [ "summon" ]
135+ obj = _get_object_spec (name , path )
136+ yield SummonDesc ( _repo , obj )
105137 except SummonError as exc :
106138 raise SummonError (
107139 str (exc ) + " at '{}' in '{}'" .format (summon_file , repo )
108- ) from exc
140+ ) from exc .__cause__
141+
142+
143+ class SummonDesc :
144+ def __init__ (self , repo , obj ):
145+ self .repo = repo
146+ self .obj = obj
147+ self ._pull_deps ()
148+
149+ @property
150+ def deps (self ):
151+ return [os .path .join (self .repo .root_dir , d ) for d in self ._deps ]
152+
153+ @property
154+ def _deps (self ):
155+ return self .obj ["summon" ].get ("deps" , [])
109156
110- _pull_dependencies (_repo , info .get ("deps" , []))
157+ def _pull_deps (self ):
158+ if not self ._deps :
159+ return
111160
112- _args = copy .deepcopy (info .get ("args" , {}))
113- _args .update (args or {})
161+ outs = [self .repo .find_out_by_relpath (d ) for d in self ._deps ]
114162
115- return _invoke_method (info ["call" ], _args , path = _repo .root_dir )
163+ with self .repo .state :
164+ for out in outs :
165+ self .repo .cloud .pull (out .get_used_cache ())
166+ out .checkout ()
116167
117168
118- def _get_object_from_summon_file (name , path ):
169+ def _get_object_spec (name , path ):
119170 """
120171 Given a summonable object's name, search for it on the given file
121172 and return its description.
122173 """
123174 try :
124- with open (path , "r" ) as fobj :
125- content = SUMMON_SCHEMA (ruamel .yaml .safe_load (fobj .read ()))
175+ with builtin_open (path , "r" ) as fobj :
176+ content = SUMMON_FILE_SCHEMA (ruamel .yaml .safe_load (fobj .read ()))
126177 objects = [x for x in content ["objects" ] if x ["name" ] == name ]
127178
128179 if not objects :
@@ -134,34 +185,20 @@ def _get_object_from_summon_file(name, path):
134185
135186 return objects [0 ]
136187
137- except FileMissingError :
138- raise SummonError ("Summon file not found" )
188+ except FileNotFoundError as exc :
189+ raise SummonError ("Summon file not found" ) from exc
139190 except ruamel .yaml .YAMLError as exc :
140191 raise SummonError ("Failed to parse summon file" ) from exc
141192 except Invalid as exc :
142- raise SummonError (str (exc ))
143-
144-
145- def _pull_dependencies (repo , deps ):
146- if not deps :
147- return
148-
149- outs = [repo .find_out_by_relpath (dep ) for dep in deps ]
150-
151- with repo .state :
152- for out in outs :
153- repo .cloud .pull (out .get_used_cache ())
154- out .checkout ()
193+ raise SummonError (str (exc )) from exc
155194
156195
196+ @wrap_with (threading .Lock ())
157197def _invoke_method (call , args , path ):
158198 # XXX: Some issues with this approach:
159- # * Not thread safe
160199 # * Import will pollute sys.modules
161- # * Weird errors if there is a name clash within sys.modules
162-
163- # XXX: sys.path manipulation is "theoretically" not needed
164- # but tests are failing for an unknown reason.
200+ # * sys.path manipulation is "theoretically" not needed,
201+ # but tests are failing for an unknown reason.
165202 cwd = os .getcwd ()
166203
167204 try :
0 commit comments