]> jfr.im git - yt-dlp.git/blob - yt_dlp/update.py
[core] Raise minimum recommended Python version to 3.8 (#8183)
[yt-dlp.git] / yt_dlp / update.py
1 import atexit
2 import contextlib
3 import hashlib
4 import json
5 import os
6 import platform
7 import re
8 import subprocess
9 import sys
10 from zipimport import zipimporter
11
12 from .compat import functools # isort: split
13 from .compat import compat_realpath, compat_shlex_quote
14 from .networking import Request
15 from .networking.exceptions import HTTPError, network_exceptions
16 from .utils import (
17 Popen,
18 cached_method,
19 deprecation_warning,
20 remove_end,
21 remove_start,
22 shell_quote,
23 system_identifier,
24 version_tuple,
25 )
26 from .version import CHANNEL, UPDATE_HINT, VARIANT, __version__
27
28 UPDATE_SOURCES = {
29 'stable': 'yt-dlp/yt-dlp',
30 'nightly': 'yt-dlp/yt-dlp-nightly-builds',
31 }
32 REPOSITORY = UPDATE_SOURCES['stable']
33
34 _VERSION_RE = re.compile(r'(\d+\.)*\d+')
35
36 API_BASE_URL = 'https://api.github.com/repos'
37
38 # Backwards compatibility variables for the current channel
39 API_URL = f'{API_BASE_URL}/{REPOSITORY}/releases'
40
41
42 @functools.cache
43 def _get_variant_and_executable_path():
44 """@returns (variant, executable_path)"""
45 if getattr(sys, 'frozen', False):
46 path = sys.executable
47 if not hasattr(sys, '_MEIPASS'):
48 return 'py2exe', path
49 elif sys._MEIPASS == os.path.dirname(path):
50 return f'{sys.platform}_dir', path
51 elif sys.platform == 'darwin':
52 machine = '_legacy' if version_tuple(platform.mac_ver()[0]) < (10, 15) else ''
53 else:
54 machine = f'_{platform.machine().lower()}'
55 # Ref: https://en.wikipedia.org/wiki/Uname#Examples
56 if machine[1:] in ('x86', 'x86_64', 'amd64', 'i386', 'i686'):
57 machine = '_x86' if platform.architecture()[0][:2] == '32' else ''
58 return f'{remove_end(sys.platform, "32")}{machine}_exe', path
59
60 path = os.path.dirname(__file__)
61 if isinstance(__loader__, zipimporter):
62 return 'zip', os.path.join(path, '..')
63 elif (os.path.basename(sys.argv[0]) in ('__main__.py', '-m')
64 and os.path.exists(os.path.join(path, '../.git/HEAD'))):
65 return 'source', path
66 return 'unknown', path
67
68
69 def detect_variant():
70 return VARIANT or _get_variant_and_executable_path()[0]
71
72
73 @functools.cache
74 def current_git_head():
75 if detect_variant() != 'source':
76 return
77 with contextlib.suppress(Exception):
78 stdout, _, _ = Popen.run(
79 ['git', 'rev-parse', '--short', 'HEAD'],
80 text=True, cwd=os.path.dirname(os.path.abspath(__file__)),
81 stdout=subprocess.PIPE, stderr=subprocess.PIPE)
82 if re.fullmatch('[0-9a-f]+', stdout.strip()):
83 return stdout.strip()
84
85
86 _FILE_SUFFIXES = {
87 'zip': '',
88 'py2exe': '_min.exe',
89 'win_exe': '.exe',
90 'win_x86_exe': '_x86.exe',
91 'darwin_exe': '_macos',
92 'darwin_legacy_exe': '_macos_legacy',
93 'linux_exe': '_linux',
94 'linux_aarch64_exe': '_linux_aarch64',
95 'linux_armv7l_exe': '_linux_armv7l',
96 }
97
98 _NON_UPDATEABLE_REASONS = {
99 **{variant: None for variant in _FILE_SUFFIXES}, # Updatable
100 **{variant: f'Auto-update is not supported for unpackaged {name} executable; Re-download the latest release'
101 for variant, name in {'win32_dir': 'Windows', 'darwin_dir': 'MacOS', 'linux_dir': 'Linux'}.items()},
102 'source': 'You cannot update when running from source code; Use git to pull the latest changes',
103 'unknown': 'You installed yt-dlp with a package manager or setup.py; Use that to update',
104 'other': 'You are using an unofficial build of yt-dlp; Build the executable again',
105 }
106
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
114
115 def _get_system_deprecation():
116 MIN_SUPPORTED, MIN_RECOMMENDED = (3, 7), (3, 8)
117
118 if sys.version_info > MIN_RECOMMENDED:
119 return None
120
121 major, minor = sys.version_info[:2]
122 if sys.version_info < MIN_SUPPORTED:
123 msg = f'Python version {major}.{minor} is no longer supported'
124 else:
125 msg = f'Support for Python version {major}.{minor} has been deprecated. '
126 # Temporary until `win_x86_exe` uses 3.8, which will deprecate Vista and Server 2008
127 if detect_variant() == 'win_x86_exe':
128 platform_name = platform.platform()
129 if any(platform_name.startswith(f'Windows-{name}') for name in ('Vista', '2008Server')):
130 msg = 'Support for Windows Vista/Server 2008 has been deprecated. '
131 else:
132 return None
133 msg += ('See https://github.com/yt-dlp/yt-dlp/issues/7803 for details.'
134 '\nYou may stop receiving updates on this version at any time')
135
136 major, minor = MIN_RECOMMENDED
137 return f'{msg}! Please update to Python {major}.{minor} or above'
138
139
140 def _sha256_file(path):
141 h = hashlib.sha256()
142 mv = memoryview(bytearray(128 * 1024))
143 with open(os.path.realpath(path), 'rb', buffering=0) as f:
144 for n in iter(lambda: f.readinto(mv), 0):
145 h.update(mv[:n])
146 return h.hexdigest()
147
148
149 class Updater:
150 _exact = True
151
152 def __init__(self, ydl, target=None):
153 self.ydl = ydl
154
155 self.target_channel, sep, self.target_tag = (target or CHANNEL).rpartition('@')
156 # stable => stable@latest
157 if not sep and ('/' in self.target_tag or self.target_tag in UPDATE_SOURCES):
158 self.target_channel = self.target_tag
159 self.target_tag = None
160 elif not self.target_channel:
161 self.target_channel = CHANNEL.partition('@')[0]
162
163 if not self.target_tag:
164 self.target_tag = 'latest'
165 self._exact = False
166 elif self.target_tag != 'latest':
167 self.target_tag = f'tags/{self.target_tag}'
168
169 if '/' in self.target_channel:
170 self._target_repo = self.target_channel
171 if self.target_channel not in (CHANNEL, *UPDATE_SOURCES.values()):
172 self.ydl.report_warning(
173 f'You are switching to an {self.ydl._format_err("unofficial", "red")} executable '
174 f'from {self.ydl._format_err(self._target_repo, self.ydl.Styles.EMPHASIS)}. '
175 f'Run {self.ydl._format_err("at your own risk", "light red")}')
176 self._block_restart('Automatically restarting into custom builds is disabled for security reasons')
177 else:
178 self._target_repo = UPDATE_SOURCES.get(self.target_channel)
179 if not self._target_repo:
180 self._report_error(
181 f'Invalid update channel {self.target_channel!r} requested. '
182 f'Valid channels are {", ".join(UPDATE_SOURCES)}', True)
183
184 def _version_compare(self, a, b, channel=CHANNEL):
185 if self._exact and channel != self.target_channel:
186 return False
187
188 if _VERSION_RE.fullmatch(f'{a}.{b}'):
189 a, b = version_tuple(a), version_tuple(b)
190 return a == b if self._exact else a >= b
191 return a == b
192
193 @functools.cached_property
194 def _tag(self):
195 if self._version_compare(self.current_version, self.latest_version):
196 return self.target_tag
197
198 identifier = f'{detect_variant()} {self.target_channel} {system_identifier()}'
199 for line in self._download('_update_spec', 'latest').decode().splitlines():
200 if not line.startswith('lock '):
201 continue
202 _, tag, pattern = line.split(' ', 2)
203 if re.match(pattern, identifier):
204 if not self._exact:
205 return f'tags/{tag}'
206 elif self.target_tag == 'latest' or not self._version_compare(
207 tag, self.target_tag[5:], channel=self.target_channel):
208 self._report_error(
209 f'yt-dlp cannot be updated above {tag} since you are on an older Python version', True)
210 return f'tags/{self.current_version}'
211 return self.target_tag
212
213 @cached_method
214 def _get_version_info(self, tag):
215 url = f'{API_BASE_URL}/{self._target_repo}/releases/{tag}'
216 self.ydl.write_debug(f'Fetching release info: {url}')
217 return json.loads(self.ydl.urlopen(Request(url, headers={
218 'Accept': 'application/vnd.github+json',
219 'User-Agent': 'yt-dlp',
220 'X-GitHub-Api-Version': '2022-11-28',
221 })).read().decode())
222
223 @property
224 def current_version(self):
225 """Current version"""
226 return __version__
227
228 @staticmethod
229 def _label(channel, tag):
230 """Label for a given channel and tag"""
231 return f'{channel}@{remove_start(tag, "tags/")}'
232
233 def _get_actual_tag(self, tag):
234 if tag.startswith('tags/'):
235 return tag[5:]
236 return self._get_version_info(tag)['tag_name']
237
238 @property
239 def new_version(self):
240 """Version of the latest release we can update to"""
241 return self._get_actual_tag(self._tag)
242
243 @property
244 def latest_version(self):
245 """Version of the target release"""
246 return self._get_actual_tag(self.target_tag)
247
248 @property
249 def has_update(self):
250 """Whether there is an update available"""
251 return not self._version_compare(self.current_version, self.new_version)
252
253 @functools.cached_property
254 def filename(self):
255 """Filename of the executable"""
256 return compat_realpath(_get_variant_and_executable_path()[1])
257
258 def _download(self, name, tag):
259 slug = 'latest/download' if tag == 'latest' else f'download/{tag[5:]}'
260 url = f'https://github.com/{self._target_repo}/releases/{slug}/{name}'
261 self.ydl.write_debug(f'Downloading {name} from {url}')
262 return self.ydl.urlopen(url).read()
263
264 @functools.cached_property
265 def release_name(self):
266 """The release filename"""
267 return f'yt-dlp{_FILE_SUFFIXES[detect_variant()]}'
268
269 @functools.cached_property
270 def release_hash(self):
271 """Hash of the latest release"""
272 hash_data = dict(ln.split()[::-1] for ln in self._download('SHA2-256SUMS', self._tag).decode().splitlines())
273 return hash_data[self.release_name]
274
275 def _report_error(self, msg, expected=False):
276 self.ydl.report_error(msg, tb=False if expected else None)
277 self.ydl._download_retcode = 100
278
279 def _report_permission_error(self, file):
280 self._report_error(f'Unable to write to {file}; Try running as administrator', True)
281
282 def _report_network_error(self, action, delim=';'):
283 self._report_error(
284 f'Unable to {action}{delim} visit '
285 f'https://github.com/{self._target_repo}/releases/{self.target_tag.replace("tags/", "tag/")}', True)
286
287 def check_update(self):
288 """Report whether there is an update available"""
289 if not self._target_repo:
290 return False
291 try:
292 self.ydl.to_screen((
293 f'Available version: {self._label(self.target_channel, self.latest_version)}, ' if self.target_tag == 'latest' else ''
294 ) + f'Current version: {self._label(CHANNEL, self.current_version)}')
295 except network_exceptions as e:
296 return self._report_network_error(f'obtain version info ({e})', delim='; Please try again later or')
297
298 if not is_non_updateable():
299 self.ydl.to_screen(f'Current Build Hash: {_sha256_file(self.filename)}')
300
301 if self.has_update:
302 return True
303
304 if self.target_tag == self._tag:
305 self.ydl.to_screen(f'yt-dlp is up to date ({self._label(CHANNEL, self.current_version)})')
306 elif not self._exact:
307 self.ydl.report_warning('yt-dlp cannot be updated any further since you are on an older Python version')
308 return False
309
310 def update(self):
311 """Update yt-dlp executable to the latest version"""
312 if not self.check_update():
313 return
314 err = is_non_updateable()
315 if err:
316 return self._report_error(err, True)
317 self.ydl.to_screen(f'Updating to {self._label(self.target_channel, self.new_version)} ...')
318 if (_VERSION_RE.fullmatch(self.target_tag[5:])
319 and version_tuple(self.target_tag[5:]) < (2023, 3, 2)):
320 self.ydl.report_warning('You are downgrading to a version without --update-to')
321 self._block_restart('Cannot automatically restart to a version without --update-to')
322
323 directory = os.path.dirname(self.filename)
324 if not os.access(self.filename, os.W_OK):
325 return self._report_permission_error(self.filename)
326 elif not os.access(directory, os.W_OK):
327 return self._report_permission_error(directory)
328
329 new_filename, old_filename = f'{self.filename}.new', f'{self.filename}.old'
330 if detect_variant() == 'zip': # Can be replaced in-place
331 new_filename, old_filename = self.filename, None
332
333 try:
334 if os.path.exists(old_filename or ''):
335 os.remove(old_filename)
336 except OSError:
337 return self._report_error('Unable to remove the old version')
338
339 try:
340 newcontent = self._download(self.release_name, self._tag)
341 except network_exceptions as e:
342 if isinstance(e, HTTPError) and e.status == 404:
343 return self._report_error(
344 f'The requested tag {self._label(self.target_channel, self.target_tag)} does not exist', True)
345 return self._report_network_error(f'fetch updates: {e}')
346
347 try:
348 expected_hash = self.release_hash
349 except Exception:
350 self.ydl.report_warning('no hash information found for the release')
351 else:
352 if hashlib.sha256(newcontent).hexdigest() != expected_hash:
353 return self._report_network_error('verify the new executable')
354
355 try:
356 with open(new_filename, 'wb') as outf:
357 outf.write(newcontent)
358 except OSError:
359 return self._report_permission_error(new_filename)
360
361 if old_filename:
362 mask = os.stat(self.filename).st_mode
363 try:
364 os.rename(self.filename, old_filename)
365 except OSError:
366 return self._report_error('Unable to move current version')
367
368 try:
369 os.rename(new_filename, self.filename)
370 except OSError:
371 self._report_error('Unable to overwrite current version')
372 return os.rename(old_filename, self.filename)
373
374 variant = detect_variant()
375 if variant.startswith('win') or variant == 'py2exe':
376 atexit.register(Popen, f'ping 127.0.0.1 -n 5 -w 1000 & del /F "{old_filename}"',
377 shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
378 elif old_filename:
379 try:
380 os.remove(old_filename)
381 except OSError:
382 self._report_error('Unable to remove the old version')
383
384 try:
385 os.chmod(self.filename, mask)
386 except OSError:
387 return self._report_error(
388 f'Unable to set permissions. Run: sudo chmod a+rx {compat_shlex_quote(self.filename)}')
389
390 self.ydl.to_screen(f'Updated yt-dlp to {self._label(self.target_channel, self.new_version)}')
391 return True
392
393 @functools.cached_property
394 def cmd(self):
395 """The command-line to run the executable, if known"""
396 # There is no sys.orig_argv in py < 3.10. Also, it can be [] when frozen
397 if getattr(sys, 'orig_argv', None):
398 return sys.orig_argv
399 elif getattr(sys, 'frozen', False):
400 return sys.argv
401
402 def restart(self):
403 """Restart the executable"""
404 assert self.cmd, 'Must be frozen or Py >= 3.10'
405 self.ydl.write_debug(f'Restarting: {shell_quote(self.cmd)}')
406 _, _, returncode = Popen.run(self.cmd)
407 return returncode
408
409 def _block_restart(self, msg):
410 def wrapper():
411 self._report_error(f'{msg}. Restart yt-dlp to use the updated version', expected=True)
412 return self.ydl._download_retcode
413 self.restart = wrapper
414
415
416 def run_update(ydl):
417 """Update the program file with the latest version from the repository
418 @returns Whether there was a successful update (No update = False)
419 """
420 return Updater(ydl).update()
421
422
423 # Deprecated
424 def update_self(to_screen, verbose, opener):
425 import traceback
426
427 deprecation_warning(f'"{__name__}.update_self" is deprecated and may be removed '
428 f'in a future version. Use "{__name__}.run_update(ydl)" instead')
429
430 printfn = to_screen
431
432 class FakeYDL():
433 to_screen = printfn
434
435 def report_warning(self, msg, *args, **kwargs):
436 return printfn(f'WARNING: {msg}', *args, **kwargs)
437
438 def report_error(self, msg, tb=None):
439 printfn(f'ERROR: {msg}')
440 if not verbose:
441 return
442 if tb is None:
443 # Copied from YoutubeDL.trouble
444 if sys.exc_info()[0]:
445 tb = ''
446 if hasattr(sys.exc_info()[1], 'exc_info') and sys.exc_info()[1].exc_info[0]:
447 tb += ''.join(traceback.format_exception(*sys.exc_info()[1].exc_info))
448 tb += traceback.format_exc()
449 else:
450 tb_data = traceback.format_list(traceback.extract_stack())
451 tb = ''.join(tb_data)
452 if tb:
453 printfn(tb)
454
455 def write_debug(self, msg, *args, **kwargs):
456 printfn(f'[debug] {msg}', *args, **kwargs)
457
458 def urlopen(self, url):
459 return opener.open(url)
460
461 return run_update(FakeYDL())
462
463
464 __all__ = ['Updater']