1 # python library used to update plugins:
2 # - pkgs/applications/editors/vim/plugins/update.py
3 # - pkgs/applications/editors/kakoune/plugins/update.py
4 # - pkgs/development/lua-modules/updater/updater.py
7 # $ nix run nixpkgs#black maintainers/scripts/pluginupdate.py
9 # $ nix run nixpkgs#python3.pkgs.mypy maintainers/scripts/pluginupdate.py
11 # $ nix run nixpkgs#python3.pkgs.flake8 -- --ignore E501,E265 maintainers/scripts/pluginupdate.py
28 import xml
.etree
.ElementTree
as ET
29 from dataclasses
import asdict
, dataclass
30 from datetime
import UTC
, datetime
31 from functools
import wraps
32 from multiprocessing
.dummy
import Pool
33 from pathlib
import Path
34 from tempfile
import NamedTemporaryFile
35 from typing
import Any
, Callable
, Dict
, List
, Optional
, Tuple
, Union
36 from urllib
.parse
import urljoin
, urlparse
40 ATOM_ENTRY
= "{http://www.w3.org/2005/Atom}entry" # " vim gets confused here
41 ATOM_LINK
= "{http://www.w3.org/2005/Atom}link" # "
42 ATOM_UPDATED
= "{http://www.w3.org/2005/Atom}updated" # "
45 logging
.getLevelName(level
): level
46 for level
in [logging
.DEBUG
, logging
.INFO
, logging
.WARN
, logging
.ERROR
]
49 log
= logging
.getLogger()
52 def retry(ExceptionToCheck
: Any
, tries
: int = 4, delay
: float = 3, backoff
: float = 2):
53 """Retry calling the decorated function using an exponential backoff.
54 http://www.saltycrane.com/blog/2009/11/trying-out-retry-decorator-python/
55 original from: http://wiki.python.org/moin/PythonDecoratorLibrary#Retry
57 :param ExceptionToCheck: the exception on which to retry
58 :param tries: number of times to try (not retry) before giving up
59 :param delay: initial delay between retries in seconds
60 :param backoff: backoff multiplier e.g. value of 2 will double the delay
64 def deco_retry(f
: Callable
) -> Callable
:
66 def f_retry(*args
: Any
, **kwargs
: Any
) -> Any
:
67 mtries
, mdelay
= tries
, delay
70 return f(*args
, **kwargs
)
71 except ExceptionToCheck
as e
:
72 print(f
"{str(e)}, Retrying in {mdelay} seconds...")
76 return f(*args
, **kwargs
)
78 return f_retry
# true decorator
89 def make_request(url
: str, token
=None) -> urllib
.request
.Request
:
92 headers
["Authorization"] = f
"token {token}"
93 return urllib
.request
.Request(url
, headers
=headers
)
96 # a dictionary of plugins and their new repositories
97 Redirects
= Dict
["PluginDesc", "Repo"]
101 def __init__(self
, uri
: str, branch
: str) -> None:
103 """Url to the repo"""
104 self
._branch
= branch
105 # Redirect is the new Repo to use
106 self
.redirect
: Optional
["Repo"] = None
107 self
.token
= "dummy_token"
111 return self
.uri
.strip("/").split("/")[-1]
115 return self
._branch
or "HEAD"
117 def __str__(self
) -> str:
120 def __repr__(self
) -> str:
121 return f
"Repo({self.name}, {self.uri})"
123 @retry(urllib
.error
.URLError
, tries
=4, delay
=3, backoff
=2)
124 def has_submodules(self
) -> bool:
127 @retry(urllib
.error
.URLError
, tries
=4, delay
=3, backoff
=2)
128 def latest_commit(self
) -> Tuple
[str, datetime
]:
129 log
.debug("Latest commit")
130 loaded
= self
._prefetch
(None)
131 updated
= datetime
.strptime(loaded
["date"], "%Y-%m-%dT%H:%M:%S%z")
133 return loaded
["rev"], updated
135 def _prefetch(self
, ref
: Optional
[str]):
136 cmd
= ["nix-prefetch-git", "--quiet", "--fetch-submodules", self
.uri
]
140 data
= subprocess
.check_output(cmd
)
141 loaded
= json
.loads(data
)
144 def prefetch(self
, ref
: Optional
[str]) -> str:
145 print("Prefetching %s", self
.uri
)
146 loaded
= self
._prefetch
(ref
)
147 return loaded
["sha256"]
149 def as_nix(self
, plugin
: "Plugin") -> str:
150 return f
"""fetchgit {{
152 rev = "{plugin.commit}";
153 sha256 = "{plugin.sha256}";
157 class RepoGitHub(Repo
):
158 def __init__(self
, owner
: str, repo
: str, branch
: str) -> None:
162 """Url to the repo"""
163 super().__init
__(self
.url(""), branch
)
165 "Instantiating github repo owner=%s and repo=%s", self
.owner
, self
.repo
172 def url(self
, path
: str) -> str:
173 res
= urljoin(f
"https://github.com/{self.owner}/{self.repo}/", path
)
176 @retry(urllib
.error
.URLError
, tries
=4, delay
=3, backoff
=2)
177 def has_submodules(self
) -> bool:
179 req
= make_request(self
.url(f
"blob/{self.branch}/.gitmodules"), self
.token
)
180 urllib
.request
.urlopen(req
, timeout
=10).close()
181 except urllib
.error
.HTTPError
as e
:
188 @retry(urllib
.error
.URLError
, tries
=4, delay
=3, backoff
=2)
189 def latest_commit(self
) -> Tuple
[str, datetime
]:
190 commit_url
= self
.url(f
"commits/{self.branch}.atom")
191 log
.debug("Sending request to %s", commit_url
)
192 commit_req
= make_request(commit_url
, self
.token
)
193 with urllib
.request
.urlopen(commit_req
, timeout
=10) as req
:
194 self
._check
_for
_redirect
(commit_url
, req
)
197 # Filter out illegal XML characters
198 illegal_xml_regex
= re
.compile(b
"[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]")
199 xml
= illegal_xml_regex
.sub(b
"", xml
)
201 root
= ET
.fromstring(xml
)
202 latest_entry
= root
.find(ATOM_ENTRY
)
203 assert latest_entry
is not None, f
"No commits found in repository {self}"
204 commit_link
= latest_entry
.find(ATOM_LINK
)
205 assert commit_link
is not None, f
"No link tag found feed entry {xml}"
206 url
= urlparse(commit_link
.get("href"))
207 updated_tag
= latest_entry
.find(ATOM_UPDATED
)
209 updated_tag
is not None and updated_tag
.text
is not None
210 ), f
"No updated tag found feed entry {xml}"
211 updated
= datetime
.strptime(updated_tag
.text
, "%Y-%m-%dT%H:%M:%SZ")
212 return Path(str(url
.path
)).name
, updated
214 def _check_for_redirect(self
, url
: str, req
: http
.client
.HTTPResponse
):
215 response_url
= req
.geturl()
216 if url
!= response_url
:
217 new_owner
, new_name
= (
218 urllib
.parse
.urlsplit(response_url
).path
.strip("/").split("/")[:2]
221 new_repo
= RepoGitHub(owner
=new_owner
, repo
=new_name
, branch
=self
.branch
)
222 self
.redirect
= new_repo
224 def prefetch(self
, commit
: str) -> str:
225 if self
.has_submodules():
226 sha256
= super().prefetch(commit
)
228 sha256
= self
.prefetch_github(commit
)
231 def prefetch_github(self
, ref
: str) -> str:
232 cmd
= ["nix-prefetch-url", "--unpack", self
.url(f
"archive/{ref}.tar.gz")]
233 log
.debug("Running %s", cmd
)
234 data
= subprocess
.check_output(cmd
)
235 return data
.strip().decode("utf-8")
237 def as_nix(self
, plugin
: "Plugin") -> str:
238 if plugin
.has_submodules
:
239 submodule_attr
= "\n fetchSubmodules = true;"
243 return f
"""fetchFromGitHub {{
244 owner = "{self.owner}";
245 repo = "{self.repo}";
246 rev = "{plugin.commit}";
247 sha256 = "{plugin.sha256}";{submodule_attr}
251 @dataclass(frozen
=True)
259 if self
.alias
is None:
260 return self
.repo
.name
264 def __lt__(self
, other
):
265 return self
.repo
.name
< other
.repo
.name
268 def load_from_csv(config
: FetchConfig
, row
: Dict
[str, str]) -> "PluginDesc":
269 log
.debug("Loading row %s", row
)
270 branch
= row
["branch"]
271 repo
= make_repo(row
["repo"], branch
.strip())
272 repo
.token
= config
.github_token
273 return PluginDesc(repo
, branch
.strip(), row
["alias"])
276 def load_from_string(config
: FetchConfig
, line
: str) -> "PluginDesc":
281 uri
, alias
= uri
.split(" as ")
282 alias
= alias
.strip()
284 uri
, branch
= uri
.split("@")
285 repo
= make_repo(uri
.strip(), branch
.strip())
286 repo
.token
= config
.github_token
287 return PluginDesc(repo
, branch
.strip(), alias
)
296 date
: Optional
[datetime
] = None
299 def normalized_name(self
) -> str:
300 return self
.name
.replace(".", "-")
303 def version(self
) -> str:
304 assert self
.date
is not None
305 return self
.date
.strftime("%Y-%m-%d")
307 def as_json(self
) -> Dict
[str, str]:
308 copy
= self
.__dict
__.copy()
313 def load_plugins_from_csv(
316 ) -> List
[PluginDesc
]:
317 log
.debug("Load plugins from csv %s", input_file
)
319 with
open(input_file
, newline
="") as csvfile
:
320 log
.debug("Writing into %s", input_file
)
321 reader
= csv
.DictReader(
325 plugin
= PluginDesc
.load_from_csv(config
, line
)
326 plugins
.append(plugin
)
332 def run_nix_expr(expr
, nixpkgs
: str, **args
):
334 :param expr nix expression to fetch current plugins
335 :param nixpkgs Path towards a nixpkgs checkout
337 with
CleanEnvironment(nixpkgs
) as nix_path
:
341 "--extra-experimental-features",
350 log
.debug("Running command: %s", " ".join(cmd
))
351 out
= subprocess
.check_output(cmd
, **args
)
352 data
= json
.loads(out
)
357 """The configuration of the update script."""
364 default_in
: Optional
[Path
] = None,
365 default_out
: Optional
[Path
] = None,
366 deprecated
: Optional
[Path
] = None,
367 cache_file
: Optional
[str] = None,
369 log
.debug("get_plugins:", get_plugins
)
372 self
.get_plugins
= get_plugins
373 self
.default_in
= default_in
or root
.joinpath(f
"{name}-plugin-names")
374 self
.default_out
= default_out
or root
.joinpath("generated.nix")
375 self
.deprecated
= deprecated
or root
.joinpath("deprecated.json")
376 self
.cache_file
= cache_file
or f
"{name}-plugin-cache.json"
377 self
.nixpkgs_repo
= None
381 log
.debug("called the 'add' command")
382 fetch_config
= FetchConfig(args
.proc
, args
.github_token
)
384 for plugin_line
in args
.add_plugins
:
385 log
.debug("using plugin_line", plugin_line
)
386 pdesc
= PluginDesc
.load_from_string(fetch_config
, plugin_line
)
387 log
.debug("loaded as pdesc", pdesc
)
389 editor
.rewrite_input(
390 fetch_config
, args
.input_file
, editor
.deprecated
, append
=append
392 plugin
, _
= prefetch_plugin(
395 autocommit
= not args
.no_commit
399 "{drv_name}: init at {version}".format(
400 drv_name
=editor
.get_drv_name(plugin
.normalized_name
),
401 version
=plugin
.version
,
403 [args
.outfile
, args
.input_file
],
406 # Expects arguments generated by 'update' subparser
407 def update(self
, args
):
409 print("the update member function should be overriden in subclasses")
411 def get_current_plugins(self
, nixpkgs
) -> List
[Plugin
]:
412 """To fill the cache"""
413 data
= run_nix_expr(self
.get_plugins
, nixpkgs
)
415 for name
, attr
in data
.items():
416 p
= Plugin(name
, attr
["rev"], attr
["submodules"], attr
["sha256"])
420 def load_plugin_spec(self
, config
: FetchConfig
, plugin_file
) -> List
[PluginDesc
]:
422 return load_plugins_from_csv(config
, plugin_file
)
424 def generate_nix(self
, _plugins
, _outfile
: str):
425 """Returns nothing for now, writes directly to outfile"""
426 raise NotImplementedError()
428 def get_update(self
, input_file
: str, outfile
: str, config
: FetchConfig
):
429 cache
: Cache
= Cache(self
.get_current_plugins(self
.nixpkgs
), self
.cache_file
)
430 _prefetch
= functools
.partial(prefetch
, cache
=cache
)
432 def update() -> dict:
433 plugins
= self
.load_plugin_spec(config
, input_file
)
436 pool
= Pool(processes
=config
.proc
)
437 results
= pool
.map(_prefetch
, plugins
)
441 plugins
, redirects
= check_results(results
)
443 self
.generate_nix(plugins
, outfile
)
451 return self
.name
+ "Plugins"
453 def get_drv_name(self
, name
: str):
454 return self
.attr_path
+ "." + name
456 def rewrite_input(self
, *args
, **kwargs
):
457 return rewrite_input(*args
, **kwargs
)
459 def create_parser(self
):
460 common
= argparse
.ArgumentParser(
464 Updates nix derivations for {self.name} plugins.\n
465 By default from {self.default_in} to {self.default_out}"""
472 help="Adjust log level",
479 default
=self
.default_in
,
480 help="A list of plugins in the form owner/repo",
486 default
=self
.default_out
,
488 help="Filename to save generated nix code",
496 help="Number of concurrent processes to spawn. Setting --github-token allows higher values.",
502 default
=os
.getenv("GITHUB_API_TOKEN"),
503 help="""Allows to set --proc to higher values.
504 Uses GITHUB_API_TOKEN environment variables as the default value.""",
511 help="Whether to autocommit changes",
516 choices
=LOG_LEVELS
.keys(),
517 default
=logging
.getLevelName(logging
.WARN
),
518 help="Adjust log level",
521 main
= argparse
.ArgumentParser(
525 Updates nix derivations for {self.name} plugins.\n
526 By default from {self.default_in} to {self.default_out}"""
530 subparsers
= main
.add_subparsers(dest
="command", required
=False)
531 padd
= subparsers
.add_parser(
534 description
="Add new plugin",
537 padd
.set_defaults(func
=self
.add
)
542 help=f
"Plugin to add to {self.attr_path} from Github in the form owner/repo",
545 pupdate
= subparsers
.add_parser(
547 description
="Update all or a subset of existing plugins",
550 pupdate
.set_defaults(func
=self
.update
)
559 parser
= self
.create_parser()
560 args
= parser
.parse_args()
561 command
= args
.command
or "update"
562 log
.setLevel(LOG_LEVELS
[args
.debug
])
563 log
.info("Chose to run command: %s", command
)
564 self
.nixpkgs
= args
.nixpkgs
566 self
.nixpkgs_repo
= git
.Repo(args
.nixpkgs
, search_parent_directories
=True)
568 getattr(self
, command
)(args
)
571 class CleanEnvironment(object):
572 def __init__(self
, nixpkgs
):
573 self
.local_pkgs
= nixpkgs
575 def __enter__(self
) -> str:
577 local_pkgs = str(Path(__file__).parent.parent.parent)
579 self
.old_environ
= os
.environ
.copy()
580 self
.empty_config
= NamedTemporaryFile()
581 self
.empty_config
.write(b
"{}")
582 self
.empty_config
.flush()
583 return f
"localpkgs={self.local_pkgs}"
585 def __exit__(self
, exc_type
: Any
, exc_value
: Any
, traceback
: Any
) -> None:
586 os
.environ
.update(self
.old_environ
)
587 self
.empty_config
.close()
592 cache
: "Optional[Cache]" = None,
593 ) -> Tuple
[Plugin
, Optional
[Repo
]]:
594 repo
, branch
, alias
= p
.repo
, p
.branch
, p
.alias
595 name
= alias
or p
.repo
.name
597 log
.info(f
"Fetching last commit for plugin {name} from {repo.uri}@{branch}")
598 commit
, date
= repo
.latest_commit()
599 cached_plugin
= cache
[commit
] if cache
else None
600 if cached_plugin
is not None:
601 log
.debug("Cache hit !")
602 cached_plugin
.name
= name
603 cached_plugin
.date
= date
604 return cached_plugin
, repo
.redirect
606 has_submodules
= repo
.has_submodules()
607 log
.debug(f
"prefetch {name}")
608 sha256
= repo
.prefetch(commit
)
611 Plugin(name
, commit
, has_submodules
, sha256
, date
=date
),
616 def print_download_error(plugin
: PluginDesc
, ex
: Exception):
617 print(f
"{plugin}: {ex}", file=sys
.stderr
)
618 ex_traceback
= ex
.__traceback
__
621 for line
in traceback
.format_exception(ex
.__class
__, ex
, ex_traceback
)
623 print("\n".join(tb_lines
))
627 results
: List
[Tuple
[PluginDesc
, Union
[Exception, Plugin
], Optional
[Repo
]]]
628 ) -> Tuple
[List
[Tuple
[PluginDesc
, Plugin
]], Redirects
]:
630 failures
: List
[Tuple
[PluginDesc
, Exception]] = []
632 redirects
: Redirects
= {}
633 for pdesc
, result
, redirect
in results
:
634 if isinstance(result
, Exception):
635 failures
.append((pdesc
, result
))
638 if redirect
is not None:
639 redirects
.update({pdesc
: redirect
})
640 new_pdesc
= PluginDesc(redirect
, pdesc
.branch
, pdesc
.alias
)
641 plugins
.append((new_pdesc
, result
))
643 print(f
"{len(results) - len(failures)} plugins were checked", end
="")
644 if len(failures
) == 0:
646 return plugins
, redirects
648 print(f
", {len(failures)} plugin(s) could not be downloaded:\n")
650 for plugin
, exception
in failures
:
651 print_download_error(plugin
, exception
)
656 def make_repo(uri
: str, branch
) -> Repo
:
657 """Instantiate a Repo with the correct specialization depending on server (gitub spec)"""
658 # dumb check to see if it's of the form owner/repo (=> github) or https://...
660 if res
.netloc
in ["github.com", ""]:
661 res
= res
.path
.strip("/").split("/")
662 repo
= RepoGitHub(res
[0], res
[1], branch
)
664 repo
= Repo(uri
.strip(), branch
)
668 def get_cache_path(cache_file_name
: str) -> Optional
[Path
]:
669 xdg_cache
= os
.environ
.get("XDG_CACHE_HOME", None)
670 if xdg_cache
is None:
671 home
= os
.environ
.get("HOME", None)
674 xdg_cache
= str(Path(home
, ".cache"))
676 return Path(xdg_cache
, cache_file_name
)
680 def __init__(self
, initial_plugins
: List
[Plugin
], cache_file_name
: str) -> None:
681 self
.cache_file
= get_cache_path(cache_file_name
)
684 for plugin
in initial_plugins
:
685 downloads
[plugin
.commit
] = plugin
686 downloads
.update(self
.load())
687 self
.downloads
= downloads
689 def load(self
) -> Dict
[str, Plugin
]:
690 if self
.cache_file
is None or not self
.cache_file
.exists():
693 downloads
: Dict
[str, Plugin
] = {}
694 with
open(self
.cache_file
) as f
:
696 for attr
in data
.values():
698 attr
["name"], attr
["commit"], attr
["has_submodules"], attr
["sha256"]
700 downloads
[attr
["commit"]] = p
703 def store(self
) -> None:
704 if self
.cache_file
is None:
707 os
.makedirs(self
.cache_file
.parent
, exist_ok
=True)
708 with
open(self
.cache_file
, "w+") as f
:
710 for name
, attr
in self
.downloads
.items():
711 data
[name
] = attr
.as_json()
712 json
.dump(data
, f
, indent
=4, sort_keys
=True)
714 def __getitem__(self
, key
: str) -> Optional
[Plugin
]:
715 return self
.downloads
.get(key
, None)
717 def __setitem__(self
, key
: str, value
: Plugin
) -> None:
718 self
.downloads
[key
] = value
722 pluginDesc
: PluginDesc
, cache
: Cache
723 ) -> Tuple
[PluginDesc
, Union
[Exception, Plugin
], Optional
[Repo
]]:
725 plugin
, redirect
= prefetch_plugin(pluginDesc
, cache
)
726 cache
[plugin
.commit
] = plugin
727 return (pluginDesc
, plugin
, redirect
)
728 except Exception as e
:
729 return (pluginDesc
, e
, None)
736 # old pluginDesc and the new
737 redirects
: Redirects
= {},
738 append
: List
[PluginDesc
] = [],
740 log
.info("Rewriting input file %s", input_file
)
741 plugins
= load_plugins_from_csv(
746 plugins
.extend(append
)
749 log
.debug("Dealing with deprecated plugins listed in %s", deprecated
)
751 cur_date_iso
= datetime
.now().strftime("%Y-%m-%d")
752 with
open(deprecated
, "r") as f
:
753 deprecations
= json
.load(f
)
754 # TODO parallelize this step
755 for pdesc
, new_repo
in redirects
.items():
756 log
.info("Rewriting input file %s", input_file
)
757 new_pdesc
= PluginDesc(new_repo
, pdesc
.branch
, pdesc
.alias
)
758 old_plugin
, _
= prefetch_plugin(pdesc
)
759 new_plugin
, _
= prefetch_plugin(new_pdesc
)
760 if old_plugin
.normalized_name
!= new_plugin
.normalized_name
:
761 deprecations
[old_plugin
.normalized_name
] = {
762 "new": new_plugin
.normalized_name
,
763 "date": cur_date_iso
,
765 with
open(deprecated
, "w") as f
:
766 json
.dump(deprecations
, f
, indent
=4, sort_keys
=True)
769 with
open(input_file
, "w") as f
:
770 log
.debug("Writing into %s", input_file
)
771 # fields = dataclasses.fields(PluginDesc)
772 fieldnames
= ["repo", "branch", "alias"]
773 writer
= csv
.DictWriter(f
, fieldnames
, dialect
="unix", quoting
=csv
.QUOTE_NONE
)
775 for plugin
in sorted(plugins
):
776 writer
.writerow(asdict(plugin
))
779 def commit(repo
: git
.Repo
, message
: str, files
: List
[Path
]) -> None:
780 repo
.index
.add([str(f
.resolve()) for f
in files
])
782 if repo
.index
.diff("HEAD"):
783 print(f
'committing to nixpkgs "{message}"')
784 repo
.index
.commit(message
)
786 print("no changes in working tree to commit")
789 def update_plugins(editor
: Editor
, args
):
790 """The main entry function of this module.
791 All input arguments are grouped in the `Editor`."""
793 log
.info("Start updating plugins")
794 fetch_config
= FetchConfig(args
.proc
, args
.github_token
)
795 update
= editor
.get_update(args
.input_file
, args
.outfile
, fetch_config
)
797 start_time
= time
.time()
799 duration
= time
.time() - start_time
800 print(f
"The plugin update took {duration:.2f}s.")
801 editor
.rewrite_input(fetch_config
, args
.input_file
, editor
.deprecated
, redirects
)
803 autocommit
= not args
.no_commit
807 repo
= git
.Repo(os
.getcwd())
808 updated
= datetime
.now(tz
=UTC
).strftime('%Y-%m-%d')
811 f
"{editor.attr_path}: update on {updated}", [args
.outfile
]
813 except git
.InvalidGitRepositoryError
as e
:
814 print(f
"Not in a git repository: {e}", file=sys
.stderr
)
822 f
"{editor.attr_path}: resolve github repository redirects",
823 [args
.outfile
, args
.input_file
, editor
.deprecated
],