@@ -17,6 +17,7 @@ import types
1717from typing import Union
1818from urllib .parse import urlparse
1919from urllib .request import urlopen
20+ from urllib .error import HTTPError
2021import venv
2122
2223
@@ -144,7 +145,8 @@ class InstInfo:
144145 target = SourceDir (self .source_loc , srctype = self .srctype )
145146 # Set recursion for how many directories deep we should search
146147 depth = 0
147- if self .srctype in [Source .DIRECTORY , Source .LOCAL_REPO ]:
148+ if self .srctype in [Source .DIRECTORY , Source .LOCAL_REPO ,
149+ Source .GIT_LOCAL_CLONE ]:
148150 depth = 5
149151 elif self .srctype == Source .GITHUB_REPO :
150152 depth = 1
@@ -193,7 +195,28 @@ class InstInfo:
193195 return success
194196 return None
195197
196- result = search_dir (self , target , False , depth )
198+ try :
199+ result = search_dir (self , target , False , depth )
200+ # Using the rest API of github.com may result in a
201+ # "Error 403: rate limit exceeded" or other access issues.
202+ # Fall back to cloning and searching the local copy instead.
203+ except HTTPError :
204+ result = None
205+ if self .srctype == Source .GITHUB_REPO :
206+ # clone source to reckless dir
207+ target = copy_remote_git_source (self )
208+ if not target :
209+ logging .warning (f"could not clone github source { self } " )
210+ return False
211+ logging .debug (f"falling back to cloning remote repo { self } " )
212+ # Update to reflect use of a local clone
213+ self .source_loc = target .location
214+ self .srctype = target .srctype
215+ result = search_dir (self , target , False , 5 )
216+
217+ if not result :
218+ return False
219+
197220 if result :
198221 if result != target :
199222 if result .relative :
@@ -235,6 +258,8 @@ class Source(Enum):
235258 GITHUB_REPO = 3
236259 OTHER_URL = 4
237260 UNKNOWN = 5
261+ # Cloned from remote source before searching (rather than github API)
262+ GIT_LOCAL_CLONE = 6
238263
239264 @classmethod
240265 def get_type (cls , source : str ):
@@ -253,6 +278,16 @@ class Source(Enum):
253278 return cls (4 )
254279 return cls (5 )
255280
281+ @classmethod
282+ def get_github_user_repo (cls , source : str ) -> (str , str ):
283+ 'extract a github username and repository name'
284+ if 'github.com/' not in source .lower ():
285+ return None , None
286+ trailing = Path (source .lower ().partition ('github.com/' )[2 ]).parts
287+ if len (trailing ) < 2 :
288+ return None , None
289+ return trailing [0 ], trailing [1 ]
290+
256291
257292class SourceDir ():
258293 """Structure to search source contents."""
@@ -277,7 +312,7 @@ class SourceDir():
277312 # logging.debug(f"populating {self.srctype} {self.location}")
278313 if self .srctype == Source .DIRECTORY :
279314 self .contents = populate_local_dir (self .location )
280- elif self .srctype == Source .LOCAL_REPO :
315+ elif self .srctype in [ Source .LOCAL_REPO , Source . GIT_LOCAL_CLONE ] :
281316 self .contents = populate_local_repo (self .location )
282317 elif self .srctype == Source .GITHUB_REPO :
283318 self .contents = populate_github_repo (self .location )
@@ -435,6 +470,11 @@ def source_element_from_repo_api(member: dict):
435470
436471
437472def populate_github_repo (url : str ) -> list :
473+ """populate one level of a github repository via REST API"""
474+ # Forces search to clone remote repos (for blackbox testing)
475+ if GITHUB_API_FALLBACK :
476+ with tempfile .NamedTemporaryFile () as tmp :
477+ raise HTTPError (url , 403 , 'simulated ratelimit' , {}, tmp )
438478 # FIXME: This probably contains leftover cruft.
439479 repo = url .split ('/' )
440480 while '' in repo :
@@ -478,6 +518,28 @@ def populate_github_repo(url: str) -> list:
478518 return contents
479519
480520
521+ def copy_remote_git_source (github_source : InstInfo ):
522+ """clone or fetch & checkout a local copy of a remote git repo"""
523+ user , repo = Source .get_github_user_repo (github_source .source_loc )
524+ if not user or not repo :
525+ logging .warning ('could not extract github user and repo '
526+ f'name for { github_source .source_loc } ' )
527+ return None
528+ local_path = RECKLESS_DIR / '.remote_sources' / user
529+ create_dir (RECKLESS_DIR / '.remote_sources' )
530+ if not create_dir (local_path ):
531+ logging .warning (f'could not provision dir { local_path } to '
532+ f'clone remote source { github_source .source_loc } ' )
533+ return None
534+ local_path = local_path / repo
535+ if local_path .exists ():
536+ # Fetch the latest
537+ assert _git_update (github_source , local_path )
538+ else :
539+ _git_clone (github_source , local_path )
540+ return SourceDir (local_path , srctype = Source .GIT_LOCAL_CLONE )
541+
542+
481543class Config ():
482544 """A generic class for procuring, reading and editing config files"""
483545 def obtain_config (self ,
@@ -803,7 +865,8 @@ def _git_clone(src: InstInfo, dest: Union[PosixPath, str]) -> bool:
803865 if src .srctype == Source .GITHUB_REPO :
804866 assert 'github.com' in src .source_loc
805867 source = f"{ GITHUB_COM } " + src .source_loc .split ("github.com" )[- 1 ]
806- elif src .srctype in [Source .LOCAL_REPO , Source .OTHER_URL ]:
868+ elif src .srctype in [Source .LOCAL_REPO , Source .OTHER_URL ,
869+ Source .GIT_LOCAL_CLONE ]:
807870 source = src .source_loc
808871 else :
809872 return False
@@ -819,6 +882,46 @@ def _git_clone(src: InstInfo, dest: Union[PosixPath, str]) -> bool:
819882 return True
820883
821884
885+ def _git_update (github_source : InstInfo , local_copy : PosixPath ):
886+ # Ensure this is the correct source
887+ git = run (['git' , 'remote' , 'set-url' , 'origin' , github_source .source_loc ],
888+ cwd = str (local_copy ), stdout = PIPE , stderr = PIPE , text = True ,
889+ check = False , timeout = 60 )
890+ assert git .returncode == 0
891+ if git .returncode != 0 :
892+ return False
893+
894+ # Fetch the latest from the remote
895+ git = run (['git' , 'fetch' , 'origin' , '--recurse-submodules=on-demand' ],
896+ cwd = str (local_copy ), stdout = PIPE , stderr = PIPE , text = True ,
897+ check = False , timeout = 60 )
898+ assert git .returncode == 0
899+ if git .returncode != 0 :
900+ return False
901+
902+ # Find default branch
903+ git = run (['git' , 'symbolic-ref' , 'refs/remotes/origin/HEAD' , '--short' ],
904+ cwd = str (local_copy ), stdout = PIPE , stderr = PIPE , text = True ,
905+ check = False , timeout = 60 )
906+ assert git .returncode == 0
907+ if git .returncode != 0 :
908+ return False
909+ default_branch = git .stdout .splitlines ()[0 ]
910+ if default_branch != 'origin/master' :
911+ logging .debug (f'UNUSUAL: fetched default branch { default_branch } for '
912+ f'{ github_source .source_loc } ' )
913+
914+ # Checkout default branch
915+ git = run (['git' , 'checkout' , default_branch ],
916+ cwd = str (local_copy ), stdout = PIPE , stderr = PIPE , text = True ,
917+ check = False , timeout = 60 )
918+ assert git .returncode == 0
919+ if git .returncode != 0 :
920+ return False
921+
922+ return True
923+
924+
822925def get_temp_reckless_dir () -> PosixPath :
823926 random_dir = 'reckless-{}' .format (str (hash (os .times ()))[- 9 :])
824927 new_path = Path (tempfile .gettempdir ()) / random_dir
@@ -850,7 +953,7 @@ def _checkout_commit(orig_src: InstInfo,
850953 cloned_path : PosixPath ):
851954 # Check out and verify commit/tag if source was a repository
852955 if orig_src .srctype in [Source .LOCAL_REPO , Source .GITHUB_REPO ,
853- Source .OTHER_URL ]:
956+ Source .OTHER_URL , Source . GIT_LOCAL_CLONE ]:
854957 if orig_src .commit :
855958 logging .debug (f"Checking out { orig_src .commit } " )
856959 checkout = Popen (['git' , 'checkout' , orig_src .commit ],
@@ -912,7 +1015,7 @@ def _install_plugin(src: InstInfo) -> Union[InstInfo, None]:
9121015 create_dir (clone_path )
9131016 shutil .copytree (src .source_loc , plugin_path )
9141017 elif src .srctype in [Source .LOCAL_REPO , Source .GITHUB_REPO ,
915- Source .OTHER_URL ]:
1018+ Source .OTHER_URL , Source . GIT_LOCAL_CLONE ]:
9161019 # clone git repository to /tmp/reckless-...
9171020 if not _git_clone (src , plugin_path ):
9181021 return None
@@ -1401,6 +1504,10 @@ if __name__ == '__main__':
14011504 GITHUB_COM = os .environ ['REDIR_GITHUB' ]
14021505 logging .root .setLevel (args .loglevel )
14031506
1507+ GITHUB_API_FALLBACK = False
1508+ if 'GITHUB_API_FALLBACK' in os .environ :
1509+ GITHUB_API_FALLBACK = os .environ ['GITHUB_API_FALLBACK' ]
1510+
14041511 if 'targets' in args :
14051512 # FIXME: Catch missing argument
14061513 if args .func .__name__ == 'help_alias' :
0 commit comments