Python fixes from `ruff check`
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 4ddd33b..5b1d3f5 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -75,7 +75,7 @@
             .\test\test_bazel.ps1
 
 jobs:
-  flake8:
+  lint:
     executor: ubuntu
     steps:
       - checkout
@@ -85,12 +85,12 @@
             apt-get update -q
             apt-get install -q -y python3-pip
       - run:
-          name: python3 flake8
+          name: python lint
           command: |
             python3 -m pip install --upgrade pip
-            python3 -m pip install flake8==7.1.1
+            python3 -m pip install flake8==7.1.1 ruff==0.14.1
             python3 -m flake8 --show-source --statistics --extend-exclude=./scripts
-
+            python3 -m ruff check
   test-linux:
     executor: ubuntu
     environment:
@@ -315,9 +315,9 @@
       - test-bazel-windows
 
 workflows:
-  flake8:
+  lint:
     jobs:
-      - flake8
+      - lint
   test-linux:
     jobs:
       - test-linux
diff --git a/bazel/emscripten_toolchain/link_wrapper.py b/bazel/emscripten_toolchain/link_wrapper.py
index 0e2573d..2215a2f 100644
--- a/bazel/emscripten_toolchain/link_wrapper.py
+++ b/bazel/emscripten_toolchain/link_wrapper.py
@@ -14,7 +14,6 @@
    bazel path.
 """
 
-from __future__ import print_function
 
 import argparse
 import os
@@ -24,7 +23,7 @@
 # Only argument should be @path/to/parameter/file
 assert sys.argv[1][0] == '@', sys.argv
 param_filename = sys.argv[1][1:]
-param_file_args = [line.strip() for line in open(param_filename, 'r').readlines()]
+param_file_args = [line.strip() for line in open(param_filename).readlines()]
 
 # Re-write response file if needed.
 if any(' ' in a for a in param_file_args):
@@ -151,7 +150,7 @@
         '--add-section=external_debug_info=' + base_name + '_debugsection.tmp'])
 
 # Make sure we have at least one output file.
-if not len(files):
+if not files:
   print('emcc.py did not appear to output any known files!')
   sys.exit(1)
 
diff --git a/emsdk.py b/emsdk.py
index 19b9947..abdfbbc 100644
--- a/emsdk.py
+++ b/emsdk.py
@@ -5,7 +5,6 @@
 # found in the LICENSE file.
 
 import copy
-from collections import OrderedDict
 import errno
 import json
 import multiprocessing
@@ -20,14 +19,16 @@
 import sysconfig
 import tarfile
 import zipfile
+from collections import OrderedDict
+
 if os.name == 'nt':
-  import winreg
   import ctypes.wintypes
+  import winreg
 
 from urllib.parse import urljoin
 from urllib.request import urlopen
 
-if sys.version_info < (3, 2):
+if sys.version_info < (3, 2):  # noqa: UP036
   print(f'error: emsdk requires python 3.2 or above ({sys.executable} {sys.version})', file=sys.stderr)
   sys.exit(1)
 
@@ -50,7 +51,10 @@
 # being run. Useful for debugging.
 VERBOSE = int(os.getenv('EMSDK_VERBOSE', '0'))
 QUIET = int(os.getenv('EMSDK_QUIET', '0'))
-TTY_OUTPUT = not os.getenv('EMSDK_NOTTY', not sys.stdout.isatty())
+if os.getenv('EMSDK_NOTTY'):
+  TTY_OUTPUT = False
+else:
+  TTY_OUTPUT = sys.stdout.isatty()
 
 
 def info(msg):
@@ -131,7 +135,7 @@
 
 # platform.machine() may return AMD64 on windows, so standardize the case.
 machine = os.getenv('EMSDK_ARCH', platform.machine().lower())
-if machine.startswith('x64') or machine.startswith('amd64') or machine.startswith('x86_64'):
+if machine.startswith(('x64', 'amd64', 'x86_64')):
   ARCH = 'x86_64'
 elif machine.endswith('86'):
   ARCH = 'x86'
@@ -323,7 +327,7 @@
         os.chmod(path, stat.S_IWRITE)
         func(path)
       else:
-        raise
+        raise exc_info[1]
     shutil.rmtree(d, onerror=remove_readonly_and_try_again)
   except Exception as e:
     debug_print('remove_tree threw an exception, ignoring: ' + str(e))
@@ -718,7 +722,7 @@
 # On success, returns the filename on the disk pointing to the destination file that was produced
 # On failure, returns None.
 def download_file(url, dstpath, download_even_if_exists=False,
-                  filename_prefix='', silent=False):
+                  filename_prefix=''):
   debug_print(f'download_file(url={url}, dstpath={dstpath})')
   file_name = get_download_target(url, dstpath, filename_prefix)
 
@@ -934,19 +938,19 @@
     return os.path.join(build_dir, 'bin')
 
 
-def build_env(generator):
-  build_env = os.environ.copy()
+def build_env():
+  env = os.environ.copy()
 
   # To work around a build issue with older Mac OS X builds, add -stdlib=libc++ to all builds.
   # See https://groups.google.com/forum/#!topic/emscripten-discuss/5Or6QIzkqf0
   if MACOS:
-    build_env['CXXFLAGS'] = ((build_env['CXXFLAGS'] + ' ') if hasattr(build_env, 'CXXFLAGS') else '') + '-stdlib=libc++'
+    env['CXXFLAGS'] = ((env['CXXFLAGS'] + ' ') if hasattr(env, 'CXXFLAGS') else '') + '-stdlib=libc++'
   if WINDOWS:
     # MSBuild.exe has an internal mechanism to avoid N^2 oversubscription of threads in its two-tier build model, see
     # https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
-    build_env['UseMultiToolTask'] = 'true'
-    build_env['EnforceProcessCountAcrossBuilds'] = 'true'
-  return build_env
+    env['UseMultiToolTask'] = 'true'
+    env['EnforceProcessCountAcrossBuilds'] = 'true'
+  return env
 
 
 # Find path to cmake executable, as one of the activated tools, in PATH, or from installed tools.
@@ -1010,7 +1014,7 @@
   # Build
   try:
     print('Running build: ' + str(make))
-    ret = subprocess.check_call(make, cwd=build_root, env=build_env(CMAKE_GENERATOR))
+    ret = subprocess.check_call(make, cwd=build_root, env=build_env())
     if ret != 0:
       errlog('Build failed with exit code {ret}!')
       errlog('Working directory: ' + build_root)
@@ -1024,7 +1028,7 @@
   return True
 
 
-def cmake_configure(generator, build_root, src_root, build_type, extra_cmake_args=[]):
+def cmake_configure(generator, build_root, src_root, build_type, extra_cmake_args):
   debug_print('cmake_configure(generator=' + str(generator) + ', build_root=' + str(build_root) + ', src_root=' + str(src_root) + ', build_type=' + str(build_type) + ', extra_cmake_args=' + str(extra_cmake_args) + ')')
   # Configure
   if not os.path.isdir(build_root):
@@ -1057,7 +1061,7 @@
     # Create a file 'recmake.bat/sh' in the build root that user can call to
     # manually recmake the build tree with the previous build params
     open(os.path.join(build_root, 'recmake.' + ('bat' if WINDOWS else 'sh')), 'w').write(' '.join(map(quote_parens, cmdline)))
-    ret = subprocess.check_call(cmdline, cwd=build_root, env=build_env(CMAKE_GENERATOR))
+    ret = subprocess.check_call(cmdline, cwd=build_root, env=build_env())
     if ret != 0:
       errlog('CMake invocation failed with exit code {ret}!')
       errlog('Working directory: ' + build_root)
@@ -1085,9 +1089,7 @@
 
 def xcode_sdk_version():
   try:
-    output = subprocess.check_output(['xcrun', '--show-sdk-version'])
-    if sys.version_info >= (3,):
-      output = output.decode('utf8')
+    output = subprocess.check_output(['xcrun', '--show-sdk-version'], universal_newlines=True)
     return output.strip().split('.')
   except Exception:
     return subprocess.checkplatform.mac_ver()[0].split('.')
@@ -1114,7 +1116,7 @@
     'arm64': 'ARM64',
     'arm': 'ARM',
     'x86_64': 'x64',
-    'x86': 'x86'
+    'x86': 'x86',
   }
   return arch_to_cmake_host_platform[ARCH]
 
@@ -1152,7 +1154,7 @@
 
   enable_assertions = ENABLE_LLVM_ASSERTIONS.lower() == 'on' or (ENABLE_LLVM_ASSERTIONS == 'auto' and build_type.lower() != 'release' and build_type.lower() != 'minsizerel')
 
-  if ARCH == 'x86' or ARCH == 'x86_64':
+  if ARCH in ('x86', 'x86_64'):
     targets_to_build = 'WebAssembly;X86'
   elif ARCH == 'arm':
     targets_to_build = 'WebAssembly;ARM'
@@ -1506,7 +1508,7 @@
 # Binaryen build scripts:
 def binaryen_build_root(tool):
   build_root = tool.installation_path().strip()
-  if build_root.endswith('/') or build_root.endswith('\\'):
+  if build_root.endswith(('/', '\\')):
     build_root = build_root[:-1]
   generator_prefix = cmake_generator_prefix()
   build_root = build_root + generator_prefix + '_' + str(tool.bitness) + 'bit_binaryen'
@@ -1561,19 +1563,18 @@
 
   url = urljoin(emsdk_packages_url, archive)
 
-  def try_download(url, silent=False):
-    return download_file(url, download_dir, not KEEP_DOWNLOADS,
-                         filename_prefix, silent=silent)
+  def try_download(url):
+    return download_file(url, download_dir, not KEEP_DOWNLOADS, filename_prefix)
 
   # Special hack for the wasm-binaries we transitioned from `.bzip2` to
   # `.xz`, but we can't tell from the version/url which one to use, so
   # try one and then fall back to the other.
   success = False
   if 'wasm-binaries' in archive and os.path.splitext(archive)[1] == '.xz':
-    success = try_download(url, silent=True)
+    success = try_download(url)
     if not success:
       alt_url = url.replace('.tar.xz', '.tbz2')
-      success = try_download(alt_url, silent=True)
+      success = try_download(alt_url)
       if success:
         url = alt_url
 
@@ -1647,7 +1648,7 @@
   EM_CONFIG_DICT.clear()
   lines = []
   try:
-    lines = open(EM_CONFIG_PATH, "r").read().split('\n')
+    lines = open(EM_CONFIG_PATH).read().split('\n')
   except Exception:
     pass
   for line in lines:
@@ -1999,14 +2000,13 @@
   def is_installed_version(self):
     version_file_path = self.get_version_file_path()
     if os.path.isfile(version_file_path):
-      with open(version_file_path, 'r') as version_file:
+      with open(version_file_path) as version_file:
         return version_file.read().strip() == self.name
     return False
 
   def update_installed_version(self):
     with open(self.get_version_file_path(), 'w') as version_file:
       version_file.write(self.name + '\n')
-    return None
 
   def is_installed(self, skip_version_check=False):
     # If this tool/sdk depends on other tools, require that all dependencies are
@@ -2178,7 +2178,7 @@
       'build_ninja': build_ninja,
       'build_ccache': build_ccache,
       'download_node_nightly': download_node_nightly,
-      'download_firefox': download_firefox
+      'download_firefox': download_firefox,
     }
     if hasattr(self, 'custom_install_script') and self.custom_install_script in custom_install_scripts:
       success = custom_install_scripts[self.custom_install_script](self)
@@ -2425,11 +2425,11 @@
 # Lists all legacy (pre-emscripten-releases) tagged versions directly in the Git
 # repositories. These we can pull and compile from source.
 def load_legacy_emscripten_tags():
-  return open(sdk_path('legacy-emscripten-tags.txt'), 'r').read().split('\n')
+  return open(sdk_path('legacy-emscripten-tags.txt')).read().split('\n')
 
 
 def load_legacy_binaryen_tags():
-  return open(sdk_path('legacy-binaryen-tags.txt'), 'r').read().split('\n')
+  return open(sdk_path('legacy-binaryen-tags.txt')).read().split('\n')
 
 
 def remove_prefix(s, prefix):
@@ -2461,7 +2461,7 @@
 def load_releases_info():
   if not hasattr(load_releases_info, 'cached_info'):
     try:
-      text = open(sdk_path('emscripten-releases-tags.json'), 'r').read()
+      text = open(sdk_path('emscripten-releases-tags.json')).read()
       load_releases_info.cached_info = json.loads(text)
     except Exception as e:
       print('Error parsing emscripten-releases-tags.json!')
@@ -2484,7 +2484,7 @@
   tags = []
   info = load_releases_info()
 
-  for version, sha in sorted(info['releases'].items(), key=lambda x: version_key(x[0])):
+  for _version, sha in sorted(info['releases'].items(), key=lambda x: version_key(x[0])):
     tags.append(sha)
 
   if extra_release_tag:
@@ -2508,7 +2508,7 @@
 
 def load_sdk_manifest():
   try:
-    manifest = json.loads(open(sdk_path("emsdk_manifest.json"), "r").read())
+    manifest = json.loads(open(sdk_path("emsdk_manifest.json")).read())
   except Exception as e:
     print('Error parsing emsdk_manifest.json!')
     print(str(e))
@@ -2679,7 +2679,7 @@
 
   if tools_to_activate:
     tools = [x for x in tools_to_activate if not x.is_sdk]
-    print('Setting the following tools as active:\n   ' + '\n   '.join(map(lambda x: str(x), tools)))
+    print('Setting the following tools as active:\n   ' + '\n   '.join([str(t) for t in tools]))
     print('')
 
   generate_em_config(tools_to_activate, permanently_activate, system)
@@ -2889,9 +2889,9 @@
   # of the SDK but we want to remove that from the current environment
   # if no such tool is active.
   # Ignore certain keys that are inputs to emsdk itself.
-  ignore_keys = set(['EMSDK_POWERSHELL', 'EMSDK_CSH', 'EMSDK_CMD', 'EMSDK_BASH', 'EMSDK_FISH',
-                     'EMSDK_NUM_CORES', 'EMSDK_NOTTY', 'EMSDK_KEEP_DOWNLOADS'])
-  env_keys_to_add = set(pair[0] for pair in env_vars_to_add)
+  ignore_keys = {'EMSDK_POWERSHELL', 'EMSDK_CSH', 'EMSDK_CMD', 'EMSDK_BASH', 'EMSDK_FISH',
+                 'EMSDK_NUM_CORES', 'EMSDK_NOTTY', 'EMSDK_KEEP_DOWNLOADS'}
+  env_keys_to_add = {pair[0] for pair in env_vars_to_add}
   for key in os.environ:
     if key.startswith('EMSDK_') or key in ('EM_CACHE', 'EM_CONFIG'):
       if key not in env_keys_to_add and key not in ignore_keys:
@@ -2955,7 +2955,7 @@
   return name
 
 
-def main(args):
+def main(args):  # noqa: C901, PLR0911, PLR0912, PLR0915
   if not args:
     errlog("Missing command; Type 'emsdk help' to get a list of commands.")
     return 1
@@ -3182,7 +3182,7 @@
       sdk = find_sdk(name)
       return 'INSTALLED' if sdk and sdk.is_installed() else ''
 
-    if (LINUX or MACOS or WINDOWS) and (ARCH == 'x86' or ARCH == 'x86_64'):
+    if (LINUX or MACOS or WINDOWS) and ARCH in ('x86', 'x86_64'):
       print('The *recommended* precompiled SDK download is %s (%s).' % (find_latest_version(), find_latest_hash()))
       print()
       print('To install/activate it use:')
@@ -3310,7 +3310,7 @@
   elif cmd == 'update-tags':
     errlog('`update-tags` is not longer needed.  To install the latest tot release just run `install tot`')
     return 0
-  elif cmd == 'activate' or cmd == 'deactivate':
+  elif cmd in ('activate', 'deactivate'):
     if arg_permanent:
       print('Registering active Emscripten environment permanently')
       print('')
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..3dbeb8b
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,74 @@
+[project]
+requires-python = ">=3.2"
+
+[tool.ruff]
+line-length = 100
+exclude = [
+  "./cache/",
+  "./node_modules/",
+  "./site/source/_themes/",
+  "./site/source/conf.py",
+  "./system/lib/",
+  "./test/third_party/",
+  "./third_party/",
+  "./tools/filelock.py",
+  "./tools/scons/",
+  ".git",
+]
+
+lint.select = [
+  "ARG",
+  "ASYNC",
+  "B",
+  "C4",
+  "C90",
+  "COM",
+  "E",
+  "F",
+  "I",
+  "PERF",
+  "PIE",
+  "PL",
+  "UP",
+  "W",
+  "YTT",
+]
+lint.external = [ "D" ]
+lint.ignore = [
+  "B011", # See https://github.com/PyCQA/flake8-bugbear/issues/66
+  "B023",
+  "B026",
+  "E402",
+  "E501",
+  "E721",
+  "E741",
+  "PERF203",
+  "PERF401",
+  "PLC0415",
+  "PLR0915",
+  "PLR1704",
+  "PLR5501",
+  "PLW0602",
+  "PLW0603",
+  "PLW1510",
+  "PLW2901",
+  "UP030", # TODO
+  "UP031", # TODO
+  "UP032", # TODO
+]
+lint.per-file-ignores."emrun.py" = [ "PLE0704" ]
+lint.per-file-ignores."tools/ports/*.py" = [ "ARG001", "ARG005" ]
+lint.per-file-ignores."test/other/ports/*.py" = [ "ARG001" ]
+lint.per-file-ignores."test/parallel_testsuite.py" = [ "ARG002" ]
+lint.per-file-ignores."test/test_benchmark.py" = [ "ARG002" ]
+lint.mccabe.max-complexity = 51 # Recommended: 10
+lint.pylint.allow-magic-value-types = [
+  "bytes",
+  "float",
+  "int",
+  "str",
+]
+lint.pylint.max-args = 15 # Recommended: 5
+lint.pylint.max-branches = 50 # Recommended: 12
+lint.pylint.max-returns = 16 # Recommended: 6
+lint.pylint.max-statements = 142 # Recommended: 50
diff --git a/scripts/update_bazel_workspace.py b/scripts/update_bazel_workspace.py
index 637c355..ac7428b 100755
--- a/scripts/update_bazel_workspace.py
+++ b/scripts/update_bazel_workspace.py
@@ -9,9 +9,10 @@
 import json
 import os
 import re
-import requests
 import sys
 
+import requests
+
 STORAGE_URL = 'https://storage.googleapis.com/webassembly/emscripten-releases-builds'
 
 EMSDK_ROOT = os.path.dirname(os.path.dirname(__file__))
@@ -51,7 +52,7 @@
 
 
 def insert_revision(item):
-    with open(BAZEL_REVISIONS_FILE, 'r') as f:
+    with open(BAZEL_REVISIONS_FILE) as f:
         lines = f.readlines()
 
     lines.insert(lines.index('EMSCRIPTEN_TAGS = {\n') + 1, item)
@@ -61,7 +62,7 @@
 
 
 def update_module_version(version):
-    with open(BAZEL_MODULE_FILE, 'r') as f:
+    with open(BAZEL_MODULE_FILE) as f:
         content = f.read()
 
     pattern = r'(module\(\s*name = "emsdk",\s*version = )"\d+.\d+.\d+",\n\)'
@@ -74,7 +75,7 @@
         f.write(content)
 
 
-def main(argv):
+def main():
     version, latest_hash = get_latest_info()
     update_module_version(version)
     item = revisions_item(version, latest_hash)
@@ -84,4 +85,4 @@
 
 
 if __name__ == '__main__':
-    sys.exit(main(sys.argv))
+    sys.exit(main())
diff --git a/scripts/update_node.py b/scripts/update_node.py
index 61e04f8..257bb74 100755
--- a/scripts/update_node.py
+++ b/scripts/update_node.py
@@ -11,11 +11,12 @@
 directory.
 """
 
-import urllib.request
-import subprocess
-import sys
 import os
 import shutil
+import subprocess
+import sys
+import urllib.request
+
 from zip import unzip_cmd, zip_cmd
 
 # When adjusting this version, visit
diff --git a/scripts/update_python.py b/scripts/update_python.py
index f80327d..a9d9d60 100755
--- a/scripts/update_python.py
+++ b/scripts/update_python.py
@@ -26,10 +26,11 @@
 import multiprocessing
 import os
 import platform
-import urllib.request
 import shutil
 import sys
+import urllib.request
 from subprocess import check_call
+
 from zip import unzip_cmd, zip_cmd
 
 version = '3.13.3'