update.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. from __future__ import annotations
  2. import atexit
  3. import contextlib
  4. import hashlib
  5. import json
  6. import os
  7. import platform
  8. import re
  9. import subprocess
  10. import sys
  11. from dataclasses import dataclass
  12. from zipimport import zipimporter
  13. from .compat import functools # isort: split
  14. from .compat import compat_realpath
  15. from .networking import Request
  16. from .networking.exceptions import HTTPError, network_exceptions
  17. from .utils import (
  18. NO_DEFAULT,
  19. Popen,
  20. deprecation_warning,
  21. format_field,
  22. remove_end,
  23. shell_quote,
  24. system_identifier,
  25. version_tuple,
  26. )
  27. from .version import (
  28. CHANNEL,
  29. ORIGIN,
  30. RELEASE_GIT_HEAD,
  31. UPDATE_HINT,
  32. VARIANT,
  33. __version__,
  34. )
  35. UPDATE_SOURCES = {
  36. 'stable': 'yt-dlp/yt-dlp',
  37. 'nightly': 'yt-dlp/yt-dlp-nightly-builds',
  38. 'master': 'yt-dlp/yt-dlp-master-builds',
  39. }
  40. REPOSITORY = UPDATE_SOURCES['stable']
  41. _INVERSE_UPDATE_SOURCES = {value: key for key, value in UPDATE_SOURCES.items()}
  42. _VERSION_RE = re.compile(r'(\d+\.)*\d+')
  43. _HASH_PATTERN = r'[\da-f]{40}'
  44. _COMMIT_RE = re.compile(rf'Generated from: https://(?:[^/?#]+/){{3}}commit/(?P<hash>{_HASH_PATTERN})')
  45. API_BASE_URL = 'https://api.github.com/repos'
  46. # Backwards compatibility variables for the current channel
  47. API_URL = f'{API_BASE_URL}/{REPOSITORY}/releases'
  48. @functools.cache
  49. def _get_variant_and_executable_path():
  50. """@returns (variant, executable_path)"""
  51. if getattr(sys, 'frozen', False):
  52. path = sys.executable
  53. if not hasattr(sys, '_MEIPASS'):
  54. return 'py2exe', path
  55. elif sys._MEIPASS == os.path.dirname(path):
  56. return f'{sys.platform}_dir', path
  57. elif sys.platform == 'darwin':
  58. machine = '_legacy' if version_tuple(platform.mac_ver()[0]) < (10, 15) else ''
  59. else:
  60. machine = f'_{platform.machine().lower()}'
  61. # Ref: https://en.wikipedia.org/wiki/Uname#Examples
  62. if machine[1:] in ('x86', 'x86_64', 'amd64', 'i386', 'i686'):
  63. machine = '_x86' if platform.architecture()[0][:2] == '32' else ''
  64. # sys.executable returns a /tmp/ path for staticx builds (linux_static)
  65. # Ref: https://staticx.readthedocs.io/en/latest/usage.html#run-time-information
  66. if static_exe_path := os.getenv('STATICX_PROG_PATH'):
  67. path = static_exe_path
  68. return f'{remove_end(sys.platform, "32")}{machine}_exe', path
  69. path = os.path.dirname(__file__)
  70. if isinstance(__loader__, zipimporter):
  71. return 'zip', os.path.join(path, '..')
  72. elif (os.path.basename(sys.argv[0]) in ('__main__.py', '-m')
  73. and os.path.exists(os.path.join(path, '../.git/HEAD'))):
  74. return 'source', path
  75. return 'unknown', path
  76. def detect_variant():
  77. return VARIANT or _get_variant_and_executable_path()[0]
  78. @functools.cache
  79. def current_git_head():
  80. if detect_variant() != 'source':
  81. return
  82. with contextlib.suppress(Exception):
  83. stdout, _, _ = Popen.run(
  84. ['git', 'rev-parse', '--short', 'HEAD'],
  85. text=True, cwd=os.path.dirname(os.path.abspath(__file__)),
  86. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  87. if re.fullmatch('[0-9a-f]+', stdout.strip()):
  88. return stdout.strip()
  89. _FILE_SUFFIXES = {
  90. 'zip': '',
  91. 'py2exe': '_min.exe',
  92. 'win_exe': '.exe',
  93. 'win_x86_exe': '_x86.exe',
  94. 'darwin_exe': '_macos',
  95. 'darwin_legacy_exe': '_macos_legacy',
  96. 'linux_exe': '_linux',
  97. 'linux_aarch64_exe': '_linux_aarch64',
  98. 'linux_armv7l_exe': '_linux_armv7l',
  99. }
  100. _NON_UPDATEABLE_REASONS = {
  101. **{variant: None for variant in _FILE_SUFFIXES}, # Updatable
  102. **{variant: f'Auto-update is not supported for unpackaged {name} executable; Re-download the latest release'
  103. for variant, name in {'win32_dir': 'Windows', 'darwin_dir': 'MacOS', 'linux_dir': 'Linux'}.items()},
  104. 'source': 'You cannot update when running from source code; Use git to pull the latest changes',
  105. 'unknown': 'You installed yt-dlp from a manual build or with a package manager; Use that to update',
  106. 'other': 'You are using an unofficial build of yt-dlp; Build the executable again',
  107. }
  108. def is_non_updateable():
  109. if UPDATE_HINT:
  110. return UPDATE_HINT
  111. return _NON_UPDATEABLE_REASONS.get(
  112. detect_variant(), _NON_UPDATEABLE_REASONS['unknown' if VARIANT else 'other'])
  113. def _get_binary_name():
  114. return format_field(_FILE_SUFFIXES, detect_variant(), template='yt-dlp%s', ignore=None, default=None)
  115. def _get_system_deprecation():
  116. MIN_SUPPORTED, MIN_RECOMMENDED = (3, 8), (3, 9)
  117. if sys.version_info > MIN_RECOMMENDED:
  118. return None
  119. major, minor = sys.version_info[:2]
  120. PYTHON_MSG = f'Please update to Python {".".join(map(str, MIN_RECOMMENDED))} or above'
  121. if sys.version_info < MIN_SUPPORTED:
  122. return f'Python version {major}.{minor} is no longer supported! {PYTHON_MSG}'
  123. EXE_MSG_TMPL = ('Support for {} has been deprecated. '
  124. 'See https://github.com/yt-dlp/yt-dlp/{} for details.\n{}')
  125. STOP_MSG = 'You may stop receiving updates on this version at any time!'
  126. variant = detect_variant()
  127. # Temporary until Windows builds use 3.9, which will drop support for Win7 and 2008ServerR2
  128. if variant in ('win_exe', 'win_x86_exe', 'py2exe'):
  129. platform_name = platform.platform()
  130. if any(platform_name.startswith(f'Windows-{name}') for name in ('7', '2008ServerR2')):
  131. return EXE_MSG_TMPL.format('Windows 7/Server 2008 R2', 'issues/10086', STOP_MSG)
  132. elif variant == 'py2exe':
  133. return EXE_MSG_TMPL.format(
  134. 'py2exe builds (yt-dlp_min.exe)', 'issues/10087',
  135. 'In a future update you will be migrated to the PyInstaller-bundled executable. '
  136. 'This will be done automatically; no action is required on your part')
  137. return None
  138. # Temporary until aarch64/armv7l build flow is bumped to Ubuntu 20.04 and Python 3.9
  139. elif variant in ('linux_aarch64_exe', 'linux_armv7l_exe'):
  140. libc_ver = version_tuple(os.confstr('CS_GNU_LIBC_VERSION').partition(' ')[2])
  141. if libc_ver < (2, 31):
  142. return EXE_MSG_TMPL.format('system glibc version < 2.31', 'pull/8638', STOP_MSG)
  143. return None
  144. return f'Support for Python version {major}.{minor} has been deprecated. {PYTHON_MSG}'
  145. def _sha256_file(path):
  146. h = hashlib.sha256()
  147. mv = memoryview(bytearray(128 * 1024))
  148. with open(os.path.realpath(path), 'rb', buffering=0) as f:
  149. for n in iter(lambda: f.readinto(mv), 0):
  150. h.update(mv[:n])
  151. return h.hexdigest()
  152. def _make_label(origin, tag, version=None):
  153. if '/' in origin:
  154. channel = _INVERSE_UPDATE_SOURCES.get(origin, origin)
  155. else:
  156. channel = origin
  157. label = f'{channel}@{tag}'
  158. if version and version != tag:
  159. label += f' build {version}'
  160. if channel != origin:
  161. label += f' from {origin}'
  162. return label
  163. @dataclass
  164. class UpdateInfo:
  165. """
  166. Update target information
  167. Can be created by `query_update()` or manually.
  168. Attributes:
  169. tag The release tag that will be updated to. If from query_update,
  170. the value is after API resolution and update spec processing.
  171. The only property that is required.
  172. version The actual numeric version (if available) of the binary to be updated to,
  173. after API resolution and update spec processing. (default: None)
  174. requested_version Numeric version of the binary being requested (if available),
  175. after API resolution only. (default: None)
  176. commit Commit hash (if available) of the binary to be updated to,
  177. after API resolution and update spec processing. (default: None)
  178. This value will only match the RELEASE_GIT_HEAD of prerelease builds.
  179. binary_name Filename of the binary to be updated to. (default: current binary name)
  180. checksum Expected checksum (if available) of the binary to be
  181. updated to. (default: None)
  182. """
  183. tag: str
  184. version: str | None = None
  185. requested_version: str | None = None
  186. commit: str | None = None
  187. binary_name: str | None = _get_binary_name() # noqa: RUF009: Always returns the same value
  188. checksum: str | None = None
  189. _has_update = True
  190. class Updater:
  191. # XXX: use class variables to simplify testing
  192. _channel = CHANNEL
  193. _origin = ORIGIN
  194. _update_sources = UPDATE_SOURCES
  195. def __init__(self, ydl, target: str | None = None):
  196. self.ydl = ydl
  197. # For backwards compat, target needs to be treated as if it could be None
  198. self.requested_channel, sep, self.requested_tag = (target or self._channel).rpartition('@')
  199. # Check if requested_tag is actually the requested repo/channel
  200. if not sep and ('/' in self.requested_tag or self.requested_tag in self._update_sources):
  201. self.requested_channel = self.requested_tag
  202. self.requested_tag: str = None # type: ignore (we set it later)
  203. elif not self.requested_channel:
  204. # User did not specify a channel, so we are requesting the default channel
  205. self.requested_channel = self._channel.partition('@')[0]
  206. # --update should not be treated as an exact tag request even if CHANNEL has a @tag
  207. self._exact = bool(target) and target != self._channel
  208. if not self.requested_tag:
  209. # User did not specify a tag, so we request 'latest' and track that no exact tag was passed
  210. self.requested_tag = 'latest'
  211. self._exact = False
  212. if '/' in self.requested_channel:
  213. # requested_channel is actually a repository
  214. self.requested_repo = self.requested_channel
  215. if not self.requested_repo.startswith('yt-dlp/') and self.requested_repo != self._origin:
  216. self.ydl.report_warning(
  217. f'You are switching to an {self.ydl._format_err("unofficial", "red")} executable '
  218. f'from {self.ydl._format_err(self.requested_repo, self.ydl.Styles.EMPHASIS)}. '
  219. f'Run {self.ydl._format_err("at your own risk", "light red")}')
  220. self._block_restart('Automatically restarting into custom builds is disabled for security reasons')
  221. else:
  222. # Check if requested_channel resolves to a known repository or else raise
  223. self.requested_repo = self._update_sources.get(self.requested_channel)
  224. if not self.requested_repo:
  225. self._report_error(
  226. f'Invalid update channel {self.requested_channel!r} requested. '
  227. f'Valid channels are {", ".join(self._update_sources)}', True)
  228. self._identifier = f'{detect_variant()} {system_identifier()}'
  229. @property
  230. def current_version(self):
  231. """Current version"""
  232. return __version__
  233. @property
  234. def current_commit(self):
  235. """Current commit hash"""
  236. return RELEASE_GIT_HEAD
  237. def _download_asset(self, name, tag=None):
  238. if not tag:
  239. tag = self.requested_tag
  240. path = 'latest/download' if tag == 'latest' else f'download/{tag}'
  241. url = f'https://github.com/{self.requested_repo}/releases/{path}/{name}'
  242. self.ydl.write_debug(f'Downloading {name} from {url}')
  243. return self.ydl.urlopen(url).read()
  244. def _call_api(self, tag):
  245. tag = f'tags/{tag}' if tag != 'latest' else tag
  246. url = f'{API_BASE_URL}/{self.requested_repo}/releases/{tag}'
  247. self.ydl.write_debug(f'Fetching release info: {url}')
  248. return json.loads(self.ydl.urlopen(Request(url, headers={
  249. 'Accept': 'application/vnd.github+json',
  250. 'User-Agent': 'yt-dlp',
  251. 'X-GitHub-Api-Version': '2022-11-28',
  252. })).read().decode())
  253. def _get_version_info(self, tag: str) -> tuple[str | None, str | None]:
  254. if _VERSION_RE.fullmatch(tag):
  255. return tag, None
  256. api_info = self._call_api(tag)
  257. if tag == 'latest':
  258. requested_version = api_info['tag_name']
  259. else:
  260. match = re.search(rf'\s+(?P<version>{_VERSION_RE.pattern})$', api_info.get('name', ''))
  261. requested_version = match.group('version') if match else None
  262. if re.fullmatch(_HASH_PATTERN, api_info.get('target_commitish', '')):
  263. target_commitish = api_info['target_commitish']
  264. else:
  265. match = _COMMIT_RE.match(api_info.get('body', ''))
  266. target_commitish = match.group('hash') if match else None
  267. if not (requested_version or target_commitish):
  268. self._report_error('One of either version or commit hash must be available on the release', expected=True)
  269. return requested_version, target_commitish
  270. def _download_update_spec(self, source_tags):
  271. for tag in source_tags:
  272. try:
  273. return self._download_asset('_update_spec', tag=tag).decode()
  274. except network_exceptions as error:
  275. if isinstance(error, HTTPError) and error.status == 404:
  276. continue
  277. self._report_network_error(f'fetch update spec: {error}')
  278. return None
  279. self._report_error(
  280. f'The requested tag {self.requested_tag} does not exist for {self.requested_repo}', True)
  281. return None
  282. def _process_update_spec(self, lockfile: str, resolved_tag: str):
  283. lines = lockfile.splitlines()
  284. is_version2 = any(line.startswith('lockV2 ') for line in lines)
  285. for line in lines:
  286. if is_version2:
  287. if not line.startswith(f'lockV2 {self.requested_repo} '):
  288. continue
  289. _, _, tag, pattern = line.split(' ', 3)
  290. else:
  291. if not line.startswith('lock '):
  292. continue
  293. _, tag, pattern = line.split(' ', 2)
  294. if re.match(pattern, self._identifier):
  295. if _VERSION_RE.fullmatch(tag):
  296. if not self._exact:
  297. return tag
  298. elif self._version_compare(tag, resolved_tag):
  299. return resolved_tag
  300. elif tag != resolved_tag:
  301. continue
  302. self._report_error(
  303. f'yt-dlp cannot be updated to {resolved_tag} since you are on an older Python version', True)
  304. return None
  305. return resolved_tag
  306. def _version_compare(self, a: str, b: str):
  307. """
  308. Compare two version strings
  309. This function SHOULD NOT be called if self._exact == True
  310. """
  311. if _VERSION_RE.fullmatch(f'{a}.{b}'):
  312. return version_tuple(a) >= version_tuple(b)
  313. return a == b
  314. def query_update(self, *, _output=False) -> UpdateInfo | None:
  315. """Fetches info about the available update
  316. @returns An `UpdateInfo` if there is an update available, else None
  317. """
  318. if not self.requested_repo:
  319. self._report_error('No target repository could be determined from input')
  320. return None
  321. try:
  322. requested_version, target_commitish = self._get_version_info(self.requested_tag)
  323. except network_exceptions as e:
  324. self._report_network_error(f'obtain version info ({e})', delim='; Please try again later or')
  325. return None
  326. if self._exact and self._origin != self.requested_repo:
  327. has_update = True
  328. elif requested_version:
  329. if self._exact:
  330. has_update = self.current_version != requested_version
  331. else:
  332. has_update = not self._version_compare(self.current_version, requested_version)
  333. elif target_commitish:
  334. has_update = target_commitish != self.current_commit
  335. else:
  336. has_update = False
  337. resolved_tag = requested_version if self.requested_tag == 'latest' else self.requested_tag
  338. current_label = _make_label(self._origin, self._channel.partition('@')[2] or self.current_version, self.current_version)
  339. requested_label = _make_label(self.requested_repo, resolved_tag, requested_version)
  340. latest_or_requested = f'{"Latest" if self.requested_tag == "latest" else "Requested"} version: {requested_label}'
  341. if not has_update:
  342. if _output:
  343. self.ydl.to_screen(f'{latest_or_requested}\nyt-dlp is up to date ({current_label})')
  344. return None
  345. update_spec = self._download_update_spec(('latest', None) if requested_version else (None,))
  346. if not update_spec:
  347. return None
  348. # `result_` prefixed vars == post-_process_update_spec() values
  349. result_tag = self._process_update_spec(update_spec, resolved_tag)
  350. if not result_tag or result_tag == self.current_version:
  351. return None
  352. elif result_tag == resolved_tag:
  353. result_version = requested_version
  354. elif _VERSION_RE.fullmatch(result_tag):
  355. result_version = result_tag
  356. else: # actual version being updated to is unknown
  357. result_version = None
  358. checksum = None
  359. # Non-updateable variants can get update_info but need to skip checksum
  360. if not is_non_updateable():
  361. try:
  362. hashes = self._download_asset('SHA2-256SUMS', result_tag)
  363. except network_exceptions as error:
  364. if not isinstance(error, HTTPError) or error.status != 404:
  365. self._report_network_error(f'fetch checksums: {error}')
  366. return None
  367. self.ydl.report_warning('No hash information found for the release, skipping verification')
  368. else:
  369. for ln in hashes.decode().splitlines():
  370. if ln.endswith(_get_binary_name()):
  371. checksum = ln.split()[0]
  372. break
  373. if not checksum:
  374. self.ydl.report_warning('The hash could not be found in the checksum file, skipping verification')
  375. if _output:
  376. update_label = _make_label(self.requested_repo, result_tag, result_version)
  377. self.ydl.to_screen(
  378. f'Current version: {current_label}\n{latest_or_requested}'
  379. + (f'\nUpgradable to: {update_label}' if update_label != requested_label else ''))
  380. return UpdateInfo(
  381. tag=result_tag,
  382. version=result_version,
  383. requested_version=requested_version,
  384. commit=target_commitish if result_tag == resolved_tag else None,
  385. checksum=checksum)
  386. def update(self, update_info=NO_DEFAULT):
  387. """Update yt-dlp executable to the latest version
  388. @param update_info `UpdateInfo | None` as returned by query_update()
  389. """
  390. if update_info is NO_DEFAULT:
  391. update_info = self.query_update(_output=True)
  392. if not update_info:
  393. return False
  394. err = is_non_updateable()
  395. if err:
  396. self._report_error(err, True)
  397. return False
  398. self.ydl.to_screen(f'Current Build Hash: {_sha256_file(self.filename)}')
  399. update_label = _make_label(self.requested_repo, update_info.tag, update_info.version)
  400. self.ydl.to_screen(f'Updating to {update_label} ...')
  401. directory = os.path.dirname(self.filename)
  402. if not os.access(self.filename, os.W_OK):
  403. return self._report_permission_error(self.filename)
  404. elif not os.access(directory, os.W_OK):
  405. return self._report_permission_error(directory)
  406. new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
  407. if detect_variant() == 'zip': # Can be replaced in-place
  408. new_filename, old_filename = self.filename, None
  409. try:
  410. if os.path.exists(old_filename or ''):
  411. os.remove(old_filename)
  412. except OSError:
  413. return self._report_error('Unable to remove the old version')
  414. try:
  415. newcontent = self._download_asset(update_info.binary_name, update_info.tag)
  416. except network_exceptions as e:
  417. if isinstance(e, HTTPError) and e.status == 404:
  418. return self._report_error(
  419. f'The requested tag {self.requested_repo}@{update_info.tag} does not exist', True)
  420. return self._report_network_error(f'fetch updates: {e}', tag=update_info.tag)
  421. if not update_info.checksum:
  422. self._block_restart('Automatically restarting into unverified builds is disabled for security reasons')
  423. elif hashlib.sha256(newcontent).hexdigest() != update_info.checksum:
  424. return self._report_network_error('verify the new executable', tag=update_info.tag)
  425. try:
  426. with open(new_filename, 'wb') as outf:
  427. outf.write(newcontent)
  428. except OSError:
  429. return self._report_permission_error(new_filename)
  430. if old_filename:
  431. mask = os.stat(self.filename).st_mode
  432. try:
  433. os.rename(self.filename, old_filename)
  434. except OSError:
  435. return self._report_error('Unable to move current version')
  436. try:
  437. os.rename(new_filename, self.filename)
  438. except OSError:
  439. self._report_error('Unable to overwrite current version')
  440. return os.rename(old_filename, self.filename)
  441. variant = detect_variant()
  442. if variant.startswith('win') or variant == 'py2exe':
  443. atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
  444. shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
  445. elif old_filename:
  446. try:
  447. os.remove(old_filename)
  448. except OSError:
  449. self._report_error('Unable to remove the old version')
  450. try:
  451. os.chmod(self.filename, mask)
  452. except OSError:
  453. return self._report_error(
  454. f'Unable to set permissions. Run: sudo chmod a+rx {shell_quote(self.filename)}')
  455. self.ydl.to_screen(f'Updated yt-dlp to {update_label}')
  456. return True
  457. @functools.cached_property
  458. def filename(self):
  459. """Filename of the executable"""
  460. return compat_realpath(_get_variant_and_executable_path()[1])
  461. @functools.cached_property
  462. def cmd(self):
  463. """The command-line to run the executable, if known"""
  464. # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
  465. if getattr(sys, 'orig_argv', None):
  466. return sys.orig_argv
  467. elif getattr(sys, 'frozen', False):
  468. return sys.argv
  469. def restart(self):
  470. """Restart the executable"""
  471. assert self.cmd, 'Must be frozen or Py >= 3.10'
  472. self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
  473. _, _, returncode = Popen.run(self.cmd)
  474. return returncode
  475. def _block_restart(self, msg):
  476. def wrapper():
  477. self._report_error(f'{msg}. Restart yt-dlp to use the updated version', expected=True)
  478. return self.ydl._download_retcode
  479. self.restart = wrapper
  480. def _report_error(self, msg, expected=False):
  481. self.ydl.report_error(msg, tb=False if expected else None)
  482. self.ydl._download_retcode = 100
  483. def _report_permission_error(self, file):
  484. self._report_error(f'Unable to write to {file}; try running as administrator', True)
  485. def _report_network_error(self, action, delim=';', tag=None):
  486. if not tag:
  487. tag = self.requested_tag
  488. path = tag if tag == 'latest' else f'tag/{tag}'
  489. self._report_error(
  490. f'Unable to {action}{delim} visit '
  491. f'https://github.com/{self.requested_repo}/releases/{path}', True)
  492. # XXX: Everything below this line in this class is deprecated / for compat only
  493. @property
  494. def _target_tag(self):
  495. """Deprecated; requested tag with 'tags/' prepended when necessary for API calls"""
  496. return f'tags/{self.requested_tag}' if self.requested_tag != 'latest' else self.requested_tag
  497. def _check_update(self):
  498. """Deprecated; report whether there is an update available"""
  499. return bool(self.query_update(_output=True))
  500. def __getattr__(self, attribute: str):
  501. """Compat getter function for deprecated attributes"""
  502. deprecated_props_map = {
  503. 'check_update': '_check_update',
  504. 'target_tag': '_target_tag',
  505. 'target_channel': 'requested_channel',
  506. }
  507. update_info_props_map = {
  508. 'has_update': '_has_update',
  509. 'new_version': 'version',
  510. 'latest_version': 'requested_version',
  511. 'release_name': 'binary_name',
  512. 'release_hash': 'checksum',
  513. }
  514. if attribute not in deprecated_props_map and attribute not in update_info_props_map:
  515. raise AttributeError(f'{type(self).__name__!r} object has no attribute {attribute!r}')
  516. msg = f'{type(self).__name__}.{attribute} is deprecated and will be removed in a future version'
  517. if attribute in deprecated_props_map:
  518. source_name = deprecated_props_map[attribute]
  519. if not source_name.startswith('_'):
  520. msg += f'. Please use {source_name!r} instead'
  521. source = self
  522. mapping = deprecated_props_map
  523. else: # attribute in update_info_props_map
  524. msg += '. Please call query_update() instead'
  525. source = self.query_update()
  526. if source is None:
  527. source = UpdateInfo('', None, None, None)
  528. source._has_update = False
  529. mapping = update_info_props_map
  530. deprecation_warning(msg)
  531. for target_name, source_name in mapping.items():
  532. value = getattr(source, source_name)
  533. setattr(self, target_name, value)
  534. return getattr(self, attribute)
  535. def run_update(ydl):
  536. """Update the program file with the latest version from the repository
  537. @returns Whether there was a successful update (No update = False)
  538. """
  539. return Updater(ydl).update()
  540. __all__ = ['Updater']