From 1b644dd0c4a4fd252306933ed2a1247ffda8de83 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 14:20:44 -0500
Subject: [PATCH 01/13] updated IO wrapper, style, version

---
 setup.py             |   2 +-
 src/cs50/__init__.py |   1 +
 src/cs50/cs50.py     |  49 +++++++----
 src/cs50/flask.py    |  10 ++-
 src/cs50/sql.py      | 199 ++++++++++++++++++++++++++++++-------------
 5 files changed, 184 insertions(+), 77 deletions(-)

diff --git a/setup.py b/setup.py
index 23f6b01..10ceb30 100644
--- a/setup.py
+++ b/setup.py
@@ -18,5 +18,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.3.0"
+    version="9.3.1"
 )
diff --git a/src/cs50/__init__.py b/src/cs50/__init__.py
index aaec161..7dd4e17 100644
--- a/src/cs50/__init__.py
+++ b/src/cs50/__init__.py
@@ -8,6 +8,7 @@
 
 # Import cs50_*
 from .cs50 import get_char, get_float, get_int, get_string
+
 try:
     from .cs50 import get_long
 except ImportError:
diff --git a/src/cs50/cs50.py b/src/cs50/cs50.py
index 07f13e9..f331a88 100644
--- a/src/cs50/cs50.py
+++ b/src/cs50/cs50.py
@@ -17,7 +17,9 @@
 
 try:
     # Patch formatException
-    logging.root.handlers[0].formatter.formatException = lambda exc_info: _formatException(*exc_info)
+    logging.root.handlers[
+        0
+    ].formatter.formatException = lambda exc_info: _formatException(*exc_info)
 except IndexError:
     pass
 
@@ -37,26 +39,31 @@
 _logger.addHandler(handler)
 
 
-class _flushfile():
+class _Unbuffered:
     """
     Disable buffering for standard output and standard error.
 
-    http://stackoverflow.com/a/231216
+    https://stackoverflow.com/a/107717
+    https://docs.python.org/3/library/io.html
     """
 
-    def __init__(self, f):
-        self.f = f
+    def __init__(self, stream):
+        self.stream = stream
 
-    def __getattr__(self, name):
-        return getattr(self.f, name)
+    def __getattr__(self, attr):
+        return getattr(self.stream, attr)
 
-    def write(self, x):
-        self.f.write(x)
-        self.f.flush()
+    def write(self, b):
+        self.stream.write(b)
+        self.stream.flush()
 
+    def writelines(self, lines):
+        self.stream.writelines(lines)
+        self.stream.flush()
 
-sys.stderr = _flushfile(sys.stderr)
-sys.stdout = _flushfile(sys.stdout)
+
+sys.stderr = _Unbuffered(sys.stderr)
+sys.stdout = _Unbuffered(sys.stdout)
 
 
 def _formatException(type, value, tb):
@@ -78,19 +85,29 @@ def _formatException(type, value, tb):
             lines += line
         else:
             matches = re.search(r"^(\s*)(.*?)(\s*)$", line, re.DOTALL)
-            lines.append(matches.group(1) + colored(matches.group(2), "yellow") + matches.group(3))
+            lines.append(
+                matches.group(1)
+                + colored(matches.group(2), "yellow")
+                + matches.group(3)
+            )
     return "".join(lines).rstrip()
 
 
-sys.excepthook = lambda type, value, tb: print(_formatException(type, value, tb), file=sys.stderr)
+sys.excepthook = lambda type, value, tb: print(
+    _formatException(type, value, tb), file=sys.stderr
+)
 
 
 def eprint(*args, **kwargs):
-    raise RuntimeError("The CS50 Library for Python no longer supports eprint, but you can use print instead!")
+    raise RuntimeError(
+        "The CS50 Library for Python no longer supports eprint, but you can use print instead!"
+    )
 
 
 def get_char(prompt):
-    raise RuntimeError("The CS50 Library for Python no longer supports get_char, but you can use get_string instead!")
+    raise RuntimeError(
+        "The CS50 Library for Python no longer supports get_char, but you can use get_string instead!"
+    )
 
 
 def get_float(prompt):
diff --git a/src/cs50/flask.py b/src/cs50/flask.py
index 3668007..6e38971 100644
--- a/src/cs50/flask.py
+++ b/src/cs50/flask.py
@@ -2,6 +2,7 @@
 import pkgutil
 import sys
 
+
 def _wrap_flask(f):
     if f is None:
         return
@@ -17,10 +18,15 @@ def _wrap_flask(f):
 
     if os.getenv("CS50_IDE_TYPE") == "online":
         from werkzeug.middleware.proxy_fix import ProxyFix
+
         _flask_init_before = f.Flask.__init__
+
         def _flask_init_after(self, *args, **kwargs):
             _flask_init_before(self, *args, **kwargs)
-            self.wsgi_app = ProxyFix(self.wsgi_app, x_proto=1)  # For HTTPS-to-HTTP proxy
+            self.wsgi_app = ProxyFix(
+                self.wsgi_app, x_proto=1
+            )  # For HTTPS-to-HTTP proxy
+
         f.Flask.__init__ = _flask_init_after
 
 
@@ -30,7 +36,7 @@ def _flask_init_after(self, *args, **kwargs):
 
 # If Flask wasn't imported
 else:
-    flask_loader = pkgutil.get_loader('flask')
+    flask_loader = pkgutil.get_loader("flask")
     if flask_loader:
         _exec_module_before = flask_loader.exec_module
 
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index de3ad56..a0b93eb 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -14,7 +14,6 @@ def _enable_logging(f):
 
     @functools.wraps(f)
     def decorator(*args, **kwargs):
-
         # Infer whether Flask is installed
         try:
             import flask
@@ -71,17 +70,20 @@ def __init__(self, url, **kwargs):
         # Create engine, disabling SQLAlchemy's own autocommit mode raising exception if back end's module not installed;
         # without isolation_level, PostgreSQL warns with "there is already a transaction in progress" for our own BEGIN and
         # "there is no transaction in progress" for our own COMMIT
-        self._engine = sqlalchemy.create_engine(url, **kwargs).execution_options(autocommit=False, isolation_level="AUTOCOMMIT")
+        self._engine = sqlalchemy.create_engine(url, **kwargs).execution_options(
+            autocommit=False, isolation_level="AUTOCOMMIT"
+        )
 
         # Get logger
         self._logger = logging.getLogger("cs50")
 
         # Listener for connections
         def connect(dbapi_connection, connection_record):
-
             # Enable foreign key constraints
             try:
-                if isinstance(dbapi_connection, sqlite3.Connection):  # If back end is sqlite
+                if isinstance(
+                    dbapi_connection, sqlite3.Connection
+                ):  # If back end is sqlite
                     cursor = dbapi_connection.cursor()
                     cursor.execute("PRAGMA foreign_keys=ON")
                     cursor.close()
@@ -150,14 +152,33 @@ def execute(self, sql, *args, **kwargs):
             raise RuntimeError("cannot pass both positional and named parameters")
 
         # Infer command from flattened statement to a single string separated by spaces
-        full_statement = ' '.join(str(token) for token in statements[0].tokens if token.ttype in [sqlparse.tokens.Keyword, sqlparse.tokens.Keyword.DDL, sqlparse.tokens.Keyword.DML])
+        full_statement = " ".join(
+            str(token)
+            for token in statements[0].tokens
+            if token.ttype
+            in [
+                sqlparse.tokens.Keyword,
+                sqlparse.tokens.Keyword.DDL,
+                sqlparse.tokens.Keyword.DML,
+            ]
+        )
         full_statement = full_statement.upper()
 
         # Set of possible commands
-        commands = {"BEGIN", "CREATE VIEW", "DELETE", "INSERT", "SELECT", "START", "UPDATE"}
+        commands = {
+            "BEGIN",
+            "CREATE VIEW",
+            "DELETE",
+            "INSERT",
+            "SELECT",
+            "START",
+            "UPDATE",
+        }
 
         # Check if the full_statement starts with any command
-        command = next((cmd for cmd in commands if full_statement.startswith(cmd)), None)
+        command = next(
+            (cmd for cmd in commands if full_statement.startswith(cmd)), None
+        )
 
         # Flatten statement
         tokens = list(statements[0].flatten())
@@ -166,10 +187,8 @@ def execute(self, sql, *args, **kwargs):
         placeholders = {}
         paramstyle = None
         for index, token in enumerate(tokens):
-
             # If token is a placeholder
             if token.ttype == sqlparse.tokens.Name.Placeholder:
-
                 # Determine paramstyle, name
                 _paramstyle, name = _parse_placeholder(token)
 
@@ -186,7 +205,6 @@ def execute(self, sql, *args, **kwargs):
 
         # If no placeholders
         if not paramstyle:
-
             # Error-check like qmark if args
             if args:
                 paramstyle = "qmark"
@@ -201,13 +219,20 @@ def execute(self, sql, *args, **kwargs):
 
         # qmark
         if paramstyle == "qmark":
-
             # Validate number of placeholders
             if len(placeholders) != len(args):
                 if len(placeholders) < len(args):
-                    raise RuntimeError("fewer placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "fewer placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
                 else:
-                    raise RuntimeError("more placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "more placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
 
             # Escape values
             for i, index in enumerate(placeholders.keys()):
@@ -215,27 +240,34 @@ def execute(self, sql, *args, **kwargs):
 
         # numeric
         elif paramstyle == "numeric":
-
             # Escape values
             for index, i in placeholders.items():
                 if i >= len(args):
-                    raise RuntimeError("missing value for placeholder (:{})".format(i + 1, len(args)))
+                    raise RuntimeError(
+                        "missing value for placeholder (:{})".format(i + 1, len(args))
+                    )
                 tokens[index] = self._escape(args[i])
 
             # Check if any values unused
             indices = set(range(len(args))) - set(placeholders.values())
             if indices:
-                raise RuntimeError("unused {} ({})".format(
-                    "value" if len(indices) == 1 else "values",
-                    ", ".join([str(self._escape(args[index])) for index in indices])))
+                raise RuntimeError(
+                    "unused {} ({})".format(
+                        "value" if len(indices) == 1 else "values",
+                        ", ".join(
+                            [str(self._escape(args[index])) for index in indices]
+                        ),
+                    )
+                )
 
         # named
         elif paramstyle == "named":
-
             # Escape values
             for index, name in placeholders.items():
                 if name not in kwargs:
-                    raise RuntimeError("missing value for placeholder (:{})".format(name))
+                    raise RuntimeError(
+                        "missing value for placeholder (:{})".format(name)
+                    )
                 tokens[index] = self._escape(kwargs[name])
 
             # Check if any keys unused
@@ -245,13 +277,20 @@ def execute(self, sql, *args, **kwargs):
 
         # format
         elif paramstyle == "format":
-
             # Validate number of placeholders
             if len(placeholders) != len(args):
                 if len(placeholders) < len(args):
-                    raise RuntimeError("fewer placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "fewer placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
                 else:
-                    raise RuntimeError("more placeholders ({}) than values ({})".format(_placeholders, _args))
+                    raise RuntimeError(
+                        "more placeholders ({}) than values ({})".format(
+                            _placeholders, _args
+                        )
+                    )
 
             # Escape values
             for i, index in enumerate(placeholders.keys()):
@@ -259,40 +298,44 @@ def execute(self, sql, *args, **kwargs):
 
         # pyformat
         elif paramstyle == "pyformat":
-
             # Escape values
             for index, name in placeholders.items():
                 if name not in kwargs:
-                    raise RuntimeError("missing value for placeholder (%{}s)".format(name))
+                    raise RuntimeError(
+                        "missing value for placeholder (%{}s)".format(name)
+                    )
                 tokens[index] = self._escape(kwargs[name])
 
             # Check if any keys unused
             keys = kwargs.keys() - placeholders.values()
             if keys:
-                raise RuntimeError("unused {} ({})".format(
-                    "value" if len(keys) == 1 else "values",
-                    ", ".join(keys)))
+                raise RuntimeError(
+                    "unused {} ({})".format(
+                        "value" if len(keys) == 1 else "values", ", ".join(keys)
+                    )
+                )
 
         # For SQL statements where a colon is required verbatim, as within an inline string, use a backslash to escape
         # https://docs.sqlalchemy.org/en/13/core/sqlelement.html?highlight=text#sqlalchemy.sql.expression.text
         for index, token in enumerate(tokens):
-
             # In string literal
             # https://www.sqlite.org/lang_keywords.html
-            if token.ttype in [sqlparse.tokens.Literal.String, sqlparse.tokens.Literal.String.Single]:
+            if token.ttype in [
+                sqlparse.tokens.Literal.String,
+                sqlparse.tokens.Literal.String.Single,
+            ]:
                 token.value = re.sub("(^'|\s+):", r"\1\:", token.value)
 
             # In identifier
             # https://www.sqlite.org/lang_keywords.html
             elif token.ttype == sqlparse.tokens.Literal.String.Symbol:
-                token.value = re.sub("(^\"|\s+):", r"\1\:", token.value)
+                token.value = re.sub('(^"|\s+):', r"\1\:", token.value)
 
         # Join tokens into statement
         statement = "".join([str(token) for token in tokens])
 
         # If no connection yet
         if not hasattr(_data, self._name()):
-
             # Connect to database
             setattr(_data, self._name(), self._engine.connect())
 
@@ -302,9 +345,12 @@ def execute(self, sql, *args, **kwargs):
         # Disconnect if/when a Flask app is torn down
         try:
             import flask
+
             assert flask.current_app
+
             def teardown_appcontext(exception):
                 self._disconnect()
+
             if teardown_appcontext not in flask.current_app.teardown_appcontext_funcs:
                 flask.current_app.teardown_appcontext(teardown_appcontext)
         except (ModuleNotFoundError, AssertionError):
@@ -312,15 +358,20 @@ def teardown_appcontext(exception):
 
         # Catch SQLAlchemy warnings
         with warnings.catch_warnings():
-
             # Raise exceptions for warnings
             warnings.simplefilter("error")
 
             # Prepare, execute statement
             try:
-
                 # Join tokens into statement, abbreviating binary data as <class 'bytes'>
-                _statement = "".join([str(bytes) if token.ttype == sqlparse.tokens.Other else str(token) for token in tokens])
+                _statement = "".join(
+                    [
+                        str(bytes)
+                        if token.ttype == sqlparse.tokens.Other
+                        else str(token)
+                        for token in tokens
+                    ]
+                )
 
                 # Check for start of transaction
                 if command in ["BEGIN", "START"]:
@@ -342,12 +393,10 @@ def teardown_appcontext(exception):
 
                 # If SELECT, return result set as list of dict objects
                 if command == "SELECT":
-
                     # Coerce types
                     rows = [dict(row) for row in result.mappings().all()]
                     for row in rows:
                         for column in row:
-
                             # Coerce decimal.Decimal objects to float objects
                             # https://groups.google.com/d/msg/sqlalchemy/0qXMYJvq8SA/oqtvMD9Uw-kJ
                             if isinstance(row[column], decimal.Decimal):
@@ -362,15 +411,15 @@ def teardown_appcontext(exception):
 
                 # If INSERT, return primary key value for a newly inserted row (or None if none)
                 elif command == "INSERT":
-
                     # If PostgreSQL
                     if self._engine.url.get_backend_name() == "postgresql":
-
                         # Return LASTVAL() or NULL, avoiding
                         # "(psycopg2.errors.ObjectNotInPrerequisiteState) lastval is not yet defined in this session",
                         # a la https://stackoverflow.com/a/24186770/5156190;
                         # cf. https://www.psycopg.org/docs/errors.html re 55000
-                        result = connection.execute(sqlalchemy.text("""
+                        result = connection.execute(
+                            sqlalchemy.text(
+                                """
                             CREATE OR REPLACE FUNCTION _LASTVAL()
                             RETURNS integer LANGUAGE plpgsql
                             AS $$
@@ -382,7 +431,9 @@ def teardown_appcontext(exception):
                                 END;
                             END $$;
                             SELECT _LASTVAL();
-                        """))
+                        """
+                            )
+                        )
                         ret = result.first()[0]
 
                     # If not PostgreSQL
@@ -405,7 +456,10 @@ def teardown_appcontext(exception):
                 raise e
 
             # If user error
-            except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.ProgrammingError) as e:
+            except (
+                sqlalchemy.exc.OperationalError,
+                sqlalchemy.exc.ProgrammingError,
+            ) as e:
                 self._disconnect()
                 self._logger.error(termcolor.colored(_statement, "red"))
                 e = RuntimeError(e.orig)
@@ -430,7 +484,6 @@ def _escape(self, value):
         import sqlparse
 
         def __escape(value):
-
             # Lazily import
             import datetime
             import sqlalchemy
@@ -439,14 +492,21 @@ def __escape(value):
             if isinstance(value, bool):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
-                    sqlalchemy.types.Boolean().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.Boolean().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # bytes
             elif isinstance(value, bytes):
                 if self._engine.url.get_backend_name() in ["mysql", "sqlite"]:
-                    return sqlparse.sql.Token(sqlparse.tokens.Other, f"x'{value.hex()}'")  # https://dev.mysql.com/doc/refman/8.0/en/hexadecimal-literals.html
+                    return sqlparse.sql.Token(
+                        sqlparse.tokens.Other, f"x'{value.hex()}'"
+                    )  # https://dev.mysql.com/doc/refman/8.0/en/hexadecimal-literals.html
                 elif self._engine.url.get_backend_name() == "postgresql":
-                    return sqlparse.sql.Token(sqlparse.tokens.Other, f"'\\x{value.hex()}'")  # https://dba.stackexchange.com/a/203359
+                    return sqlparse.sql.Token(
+                        sqlparse.tokens.Other, f"'\\x{value.hex()}'"
+                    )  # https://dba.stackexchange.com/a/203359
                 else:
                     raise RuntimeError("unsupported value: {}".format(value))
 
@@ -454,43 +514,59 @@ def __escape(value):
             elif isinstance(value, datetime.datetime):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value.strftime("%Y-%m-%d %H:%M:%S")))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value.strftime("%Y-%m-%d %H:%M:%S")
+                    ),
+                )
 
             # datetime.date
             elif isinstance(value, datetime.date):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value.strftime("%Y-%m-%d")))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value.strftime("%Y-%m-%d")
+                    ),
+                )
 
             # datetime.time
             elif isinstance(value, datetime.time):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value.strftime("%H:%M:%S")))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value.strftime("%H:%M:%S")
+                    ),
+                )
 
             # float
             elif isinstance(value, float):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
-                    sqlalchemy.types.Float().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.Float().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # int
             elif isinstance(value, int):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
-                    sqlalchemy.types.Integer().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.Integer().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # str
             elif isinstance(value, str):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
-                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.types.String().literal_processor(self._engine.dialect)(
+                        value
+                    ),
+                )
 
             # None
             elif value is None:
-                return sqlparse.sql.Token(
-                    sqlparse.tokens.Keyword,
-                    sqlalchemy.null())
+                return sqlparse.sql.Token(sqlparse.tokens.Keyword, sqlalchemy.null())
 
             # Unsupported value
             else:
@@ -498,7 +574,9 @@ def __escape(value):
 
         # Escape value(s), separating with commas as needed
         if isinstance(value, (list, tuple)):
-            return sqlparse.sql.TokenList(sqlparse.parse(", ".join([str(__escape(v)) for v in value])))
+            return sqlparse.sql.TokenList(
+                sqlparse.parse(", ".join([str(__escape(v)) for v in value]))
+            )
         else:
             return __escape(value)
 
@@ -510,7 +588,9 @@ def _parse_exception(e):
     import re
 
     # MySQL
-    matches = re.search(r"^\(_mysql_exceptions\.OperationalError\) \(\d+, \"(.+)\"\)$", str(e))
+    matches = re.search(
+        r"^\(_mysql_exceptions\.OperationalError\) \(\d+, \"(.+)\"\)$", str(e)
+    )
     if matches:
         return matches.group(1)
 
@@ -536,7 +616,10 @@ def _parse_placeholder(token):
     import sqlparse
 
     # Validate token
-    if not isinstance(token, sqlparse.sql.Token) or token.ttype != sqlparse.tokens.Name.Placeholder:
+    if (
+        not isinstance(token, sqlparse.sql.Token)
+        or token.ttype != sqlparse.tokens.Name.Placeholder
+    ):
         raise TypeError()
 
     # qmark

From 14a9741b904a437af015e0aa9d14d8531bc18de2 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 14:40:45 -0500
Subject: [PATCH 02/13] increments version

---
 setup.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/setup.py b/setup.py
index 10ceb30..8f4b1be 100644
--- a/setup.py
+++ b/setup.py
@@ -18,5 +18,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.3.1"
+    version="9.3.2"
 )

From fc6647adbf1bb67cbae8e29e01095d550ed04d2e Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 19:16:30 -0500
Subject: [PATCH 03/13] updated style

---
 setup.py        |  2 +-
 src/cs50/sql.py | 13 ++++++++++++-
 tests/sql.py    |  6 ++++++
 3 files changed, 19 insertions(+), 2 deletions(-)

diff --git a/setup.py b/setup.py
index 8f4b1be..7976109 100644
--- a/setup.py
+++ b/setup.py
@@ -18,5 +18,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.3.2"
+    version="9.3.3"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 3be30fe..356ed62 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -71,9 +71,13 @@ def __init__(self, url, **kwargs):
         # without isolation_level, PostgreSQL warns with "there is already a transaction in progress" for our own BEGIN and
         # "there is no transaction in progress" for our own COMMIT
         self._engine = sqlalchemy.create_engine(url, **kwargs).execution_options(
-            autocommit=False, isolation_level="AUTOCOMMIT"
+            autocommit=False, isolation_level="AUTOCOMMIT", no_parameters=True
         )
 
+        # Avoid doubly escaping percent signs, since no_parameters=True anyway
+        # https://github.com/cs50/python-cs50/issues/171
+        self._engine.dialect.identifier_preparer._double_percents = False
+
         # Get logger
         self._logger = logging.getLogger("cs50")
 
@@ -559,12 +563,19 @@ def __escape(value):
 
             # str
             elif isinstance(value, str):
+<<<<<<< HEAD
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
                     sqlalchemy.types.String().literal_processor(self._engine.dialect)(
                         value
                     ),
                 )
+=======
+                literal = sqlalchemy.types.String().literal_processor(self._engine.dialect)(value)
+                #if self._engine.dialect.identifier_preparer._double_percents:
+                #    literal = literal.replace("%%", "%")
+                return sqlparse.sql.Token(sqlparse.tokens.String, literal)
+>>>>>>> 3863555 (fixes #171)
 
             # None
             elif value is None:
diff --git a/tests/sql.py b/tests/sql.py
index 968f98b..b5d5406 100644
--- a/tests/sql.py
+++ b/tests/sql.py
@@ -138,6 +138,12 @@ def test_lastrowid(self):
         self.assertEqual(self.db.execute("INSERT INTO foo (firstname, lastname) VALUES('firstname', 'lastname')"), 1)
         self.assertRaises(ValueError, self.db.execute, "INSERT INTO foo (id, firstname, lastname) VALUES(1, 'firstname', 'lastname')")
 
+    def test_url(self):
+        url = "https://www.amazon.es/Desesperaci%C3%B3n-BEST-SELLER-Stephen-King/dp/8497595890"
+        self.db.execute("CREATE TABLE foo(id SERIAL PRIMARY KEY, url TEXT)")
+        self.db.execute("INSERT INTO foo (url) VALUES(?)", url)
+        self.assertEqual(self.db.execute("SELECT url FROM foo")[0]["url"], url)
+
     def tearDown(self):
         self.db.execute("DROP TABLE cs50")
         self.db.execute("DROP TABLE IF EXISTS foo")

From 128f498e8e34f2db10c06633b2f7d2b742ecc2c9 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 19:15:37 -0500
Subject: [PATCH 04/13] fixed YAML

---
 docker-compose.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/docker-compose.yml b/docker-compose.yml
index f795750..91f5a7d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,7 +7,7 @@ services:
       - postgres 
     environment:
       MYSQL_HOST: mysql
-      POSTGRESQL_HOST: postgresql
+      POSTGRESQL_HOST: postgres
     links:
       - mysql
       - postgres

From 3b9036933da29b0265f7ddae0d709ed2784cfb1d Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 19:18:14 -0500
Subject: [PATCH 05/13] fixed merge

---
 src/cs50/sql.py | 7 -------
 1 file changed, 7 deletions(-)

diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 356ed62..cbbd3d2 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -563,19 +563,12 @@ def __escape(value):
 
             # str
             elif isinstance(value, str):
-<<<<<<< HEAD
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
                     sqlalchemy.types.String().literal_processor(self._engine.dialect)(
                         value
                     ),
                 )
-=======
-                literal = sqlalchemy.types.String().literal_processor(self._engine.dialect)(value)
-                #if self._engine.dialect.identifier_preparer._double_percents:
-                #    literal = literal.replace("%%", "%")
-                return sqlparse.sql.Token(sqlparse.tokens.String, literal)
->>>>>>> 3863555 (fixes #171)
 
             # None
             elif value is None:

From e47cc3563cb015159ab897dd8053af2f887f7907 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 19:39:15 -0500
Subject: [PATCH 06/13] updated for MySQL tests

---
 docker-compose.yml | 2 +-
 tests/sql.py       | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/docker-compose.yml b/docker-compose.yml
index 91f5a7d..8608080 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -20,7 +20,7 @@ services:
       MYSQL_ALLOW_EMPTY_PASSWORD: yes
     healthcheck:
       test: ["CMD", "mysqladmin", "-uroot", "ping"]
-    image: cs50/mysql:8
+    image: cs50/mysql
     ports:
       - 3306:3306
   postgres:
diff --git a/tests/sql.py b/tests/sql.py
index b5d5406..bb37fd9 100644
--- a/tests/sql.py
+++ b/tests/sql.py
@@ -336,7 +336,7 @@ def test_cte(self):
 if __name__ == "__main__":
     suite = unittest.TestSuite([
         unittest.TestLoader().loadTestsFromTestCase(SQLiteTests),
-        #unittest.TestLoader().loadTestsFromTestCase(MySQLTests),
+        unittest.TestLoader().loadTestsFromTestCase(MySQLTests),
         unittest.TestLoader().loadTestsFromTestCase(PostgresTests)
     ])
 

From dc6a43f5f93cd22457873ee858a60cbac46237f6 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 23:04:50 -0500
Subject: [PATCH 07/13] updated SQLAlchemy versioning

---
 setup.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/setup.py b/setup.py
index 7976109..6fd6cfa 100644
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@
         "Topic :: Software Development :: Libraries :: Python Modules"
     ],
     description="CS50 library for Python",
-    install_requires=["Flask>=1.0", "packaging", "SQLAlchemy<3", "sqlparse", "termcolor", "wheel"],
+    install_requires=["Flask>=1.0", "packaging", "SQLAlchemy>=2,<3", "sqlparse", "termcolor", "wheel"],
     keywords="cs50",
     license="GPLv3",
     long_description_content_type="text/markdown",
@@ -18,5 +18,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.3.3"
+    version="9.3.4"
 )

From 688af684d61a01e3bec5008acf2293a6d505196f Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Tue, 5 Mar 2024 00:23:11 +0700
Subject: [PATCH 08/13] updated workflow actions

---
 .github/workflows/main.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index b8165f7..0f14704 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -20,8 +20,8 @@ jobs:
         ports:
         - 5432:5432
     steps:
-    - uses: actions/checkout@v3
-    - uses: actions/setup-python@v4
+    - uses: actions/checkout@v4
+    - uses: actions/setup-python@v5
       with:
         python-version: '3.7'
         check-latest: true

From d67a26b3891a8d093aaa3a53e0f9a7bfeb0a599e Mon Sep 17 00:00:00 2001
From: "yulai.linda@gmail.com" <rongxinliu.dev@gmail.com>
Date: Thu, 2 May 2024 12:22:33 -0400
Subject: [PATCH 09/13] updated actions/github-script to version v7

---
 .github/workflows/main.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 0f14704..0b0ee1a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -56,7 +56,7 @@ jobs:
 
     - name: Create Release
       if: ${{ github.ref == 'refs/heads/main' }}
-      uses: actions/github-script@v6
+      uses: actions/github-script@v7
       with:
         github-token: ${{ github.token }}
         script: |

From 2d5fd94132accb2733833eb273d755803a99794c Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Mon, 14 Oct 2024 20:39:22 -0400
Subject: [PATCH 10/13] adds support for VACUUM

---
 setup.py        | 2 +-
 src/cs50/sql.py | 5 +++--
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/setup.py b/setup.py
index 6fd6cfa..1817b95 100644
--- a/setup.py
+++ b/setup.py
@@ -18,5 +18,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.3.4"
+    version="9.4.0"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index cbbd3d2..a133293 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -177,6 +177,7 @@ def execute(self, sql, *args, **kwargs):
             "SELECT",
             "START",
             "UPDATE",
+            "VACUUM",
         }
 
         # Check if the full_statement starts with any command
@@ -378,7 +379,7 @@ def teardown_appcontext(exception):
                 )
 
                 # Check for start of transaction
-                if command in ["BEGIN", "START"]:
+                if command in ["BEGIN", "START", "VACUUM"]:  # cannot VACUUM from within a transaction
                     self._autocommit = False
 
                 # Execute statement
@@ -389,7 +390,7 @@ def teardown_appcontext(exception):
                     connection.execute(sqlalchemy.text("COMMIT"))
 
                 # Check for end of transaction
-                if command in ["COMMIT", "ROLLBACK"]:
+                if command in ["COMMIT", "ROLLBACK", "VACUUM"]:  # cannot VACUUM from within a transaction
                     self._autocommit = True
 
                 # Return value

From f81a0a3920d90296537843d45683ec0b5d70171b Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Mon, 14 Oct 2024 20:40:31 -0400
Subject: [PATCH 11/13] fixes raw strings

---
 src/cs50/sql.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index a133293..81905bc 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -329,12 +329,12 @@ def execute(self, sql, *args, **kwargs):
                 sqlparse.tokens.Literal.String,
                 sqlparse.tokens.Literal.String.Single,
             ]:
-                token.value = re.sub("(^'|\s+):", r"\1\:", token.value)
+                token.value = re.sub(r"(^'|\s+):", r"\1\:", token.value)
 
             # In identifier
             # https://www.sqlite.org/lang_keywords.html
             elif token.ttype == sqlparse.tokens.Literal.String.Symbol:
-                token.value = re.sub('(^"|\s+):', r"\1\:", token.value)
+                token.value = re.sub(r'(^"|\s+):', r"\1\:", token.value)
 
         # Join tokens into statement
         statement = "".join([str(token) for token in tokens])

From b629b6dcdf3041982117801a35c01be2b72cb3d3 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sat, 5 Apr 2025 11:36:15 -0400
Subject: [PATCH 12/13] Update README.md

---
 README.md | 47 -----------------------------------------------
 1 file changed, 47 deletions(-)

diff --git a/README.md b/README.md
index c94bd1f..a9033c6 100644
--- a/README.md
+++ b/README.md
@@ -39,50 +39,3 @@ s = cs50.get_string();
     ```
     python tests/sql.py
     ```
-
-### Sample Tests
-
-```
-import cs50
-db = cs50.SQL("sqlite:///foo.db")
-db.execute("CREATE TABLE IF NOT EXISTS cs50 (id INTEGER PRIMARY KEY, val TEXT, bin BLOB)")
-db.execute("INSERT INTO cs50 (val) VALUES('a')")
-db.execute("INSERT INTO cs50 (val) VALUES('b')")
-db.execute("BEGIN")
-db.execute("INSERT INTO cs50 (val) VALUES('c')")
-db.execute("INSERT INTO cs50 (val) VALUES('x')")
-db.execute("INSERT INTO cs50 (val) VALUES('y')")
-db.execute("ROLLBACK")
-db.execute("INSERT INTO cs50 (val) VALUES('z')")
-db.execute("COMMIT")
-
----
-
-import cs50
-db = cs50.SQL("mysql://root@localhost/test")
-db.execute("CREATE TABLE IF NOT EXISTS cs50 (id INTEGER PRIMARY KEY, val TEXT, bin BLOB)")
-db.execute("INSERT INTO cs50 (val) VALUES('a')")
-db.execute("INSERT INTO cs50 (val) VALUES('b')")
-db.execute("BEGIN")
-db.execute("INSERT INTO cs50 (val) VALUES('c')")
-db.execute("INSERT INTO cs50 (val) VALUES('x')")
-db.execute("INSERT INTO cs50 (val) VALUES('y')")
-db.execute("ROLLBACK")
-db.execute("INSERT INTO cs50 (val) VALUES('z')")
-db.execute("COMMIT")
-
----
-
-import cs50
-db = cs50.SQL("postgresql://postgres@localhost/test")
-db.execute("CREATE TABLE IF NOT EXISTS cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16), bin BYTEA)")
-db.execute("INSERT INTO cs50 (val) VALUES('a')")
-db.execute("INSERT INTO cs50 (val) VALUES('b')")
-db.execute("BEGIN")
-db.execute("INSERT INTO cs50 (val) VALUES('c')")
-db.execute("INSERT INTO cs50 (val) VALUES('x')")
-db.execute("INSERT INTO cs50 (val) VALUES('y')")
-db.execute("ROLLBACK")
-db.execute("INSERT INTO cs50 (val) VALUES('z')")
-db.execute("COMMIT")
-```

From 68f4380757d8adb92d027c42c2099e798bff9488 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sat, 5 Apr 2025 11:38:49 -0400
Subject: [PATCH 13/13] updated Python for Action

---
 .github/workflows/main.yml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 0b0ee1a..7fcb507 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -23,7 +23,7 @@ jobs:
     - uses: actions/checkout@v4
     - uses: actions/setup-python@v5
       with:
-        python-version: '3.7'
+        python-version: '3.12'
         check-latest: true
     - name: Setup databases
       run: |