From d06017f86bd544f31bd40fc3b6f946d3c7af7c65 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 5 Jun 2022 21:12:03 -0400
Subject: [PATCH 01/44] using INFO, WARNING, and ERROR instead of DEBUG

---
 setup.py        |  2 +-
 src/cs50/sql.py |  6 +++---
 tests/foo.py    | 11 ++++++-----
 3 files changed, 10 insertions(+), 9 deletions(-)

diff --git a/setup.py b/setup.py
index d1c4f9b..c363ff1 100644
--- a/setup.py
+++ b/setup.py
@@ -16,5 +16,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.0.0"
+    version="10.0.0"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index b0aa94e..8f0a1be 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -384,7 +384,7 @@ def teardown_appcontext(exception):
 
             # If constraint violated, return None
             except sqlalchemy.exc.IntegrityError as e:
-                self._logger.debug(termcolor.colored(statement, "yellow"))
+                self._logger.warning(termcolor.colored(statement, "yellow"))
                 e = ValueError(e.orig)
                 e.__cause__ = None
                 raise e
@@ -392,14 +392,14 @@ def teardown_appcontext(exception):
             # If user error
             except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.ProgrammingError) as e:
                 self._disconnect()
-                self._logger.debug(termcolor.colored(statement, "red"))
+                self._logger.error(termcolor.colored(statement, "red"))
                 e = RuntimeError(e.orig)
                 e.__cause__ = None
                 raise e
 
             # Return value
             else:
-                self._logger.debug(termcolor.colored(_statement, "green"))
+                self._logger.info(termcolor.colored(_statement, "green"))
                 if self._autocommit:  # Don't stay connected unnecessarily
                     self._disconnect()
                 return ret
diff --git a/tests/foo.py b/tests/foo.py
index 7f32a00..f3955fc 100644
--- a/tests/foo.py
+++ b/tests/foo.py
@@ -5,23 +5,23 @@
 
 import cs50
 
-"""
 db = cs50.SQL("sqlite:///foo.db")
 
 logging.getLogger("cs50").disabled = False
+logging.getLogger("cs50").setLevel(logging.ERROR)
 
-#db.execute("SELECT ? FROM ? ORDER BY ?", "a", "tbl", "c")
-db.execute("CREATE TABLE IF NOT EXISTS bar (firstname STRING)")
+db.execute("CREATE TABLE IF NOT EXISTS bar (firstname STRING UNIQUE)")
 
+db.execute("INSERT INTO bar VALUES (?)", "baz")
 db.execute("INSERT INTO bar VALUES (?)", "baz")
 db.execute("INSERT INTO bar VALUES (?)", "qux")
 db.execute("SELECT * FROM bar WHERE firstname IN (?)", ("baz", "qux"))
 db.execute("DELETE FROM bar")
+
 """
 
 db = cs50.SQL("postgresql://postgres@localhost/test")
 
-"""
 print(db.execute("DROP TABLE IF EXISTS cs50"))
 print(db.execute("CREATE TABLE cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16), bin BYTEA)"))
 print(db.execute("INSERT INTO cs50 (val) VALUES('foo')"))
@@ -31,7 +31,6 @@
 print(db.execute("CREATE TABLE cs50 (val VARCHAR(16), bin BYTEA)"))
 print(db.execute("INSERT INTO cs50 (val) VALUES('foo')"))
 print(db.execute("SELECT * FROM cs50"))
-"""
 
 print(db.execute("DROP TABLE IF EXISTS cs50"))
 print(db.execute("CREATE TABLE cs50 (id SERIAL PRIMARY KEY, val VARCHAR(16), bin BYTEA)"))
@@ -46,3 +45,5 @@
     pass
 print(db.execute("INSERT INTO cs50 (val) VALUES('qux')"))
 #print(db.execute("DELETE FROM cs50"))
+
+"""

From afb51bc6163e9e82653e1aa251cd0e46afc10515 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Mon, 6 Jun 2022 09:18:25 -0400
Subject: [PATCH 02/44] changing minor version instead

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

diff --git a/setup.py b/setup.py
index c363ff1..0eb27bf 100644
--- a/setup.py
+++ b/setup.py
@@ -16,5 +16,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="10.0.0"
+    version="9.1.0"
 )

From 6a5bee036c16be15576c815c1918651f00fc8cca Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Mon, 6 Jun 2022 09:21:27 -0400
Subject: [PATCH 03/44] changed warning to error

---
 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 8f0a1be..cd631fc 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -382,9 +382,9 @@ def teardown_appcontext(exception):
                 elif command in ["DELETE", "UPDATE"]:
                     ret = result.rowcount
 
-            # If constraint violated, return None
+            # If constraint violated
             except sqlalchemy.exc.IntegrityError as e:
-                self._logger.warning(termcolor.colored(statement, "yellow"))
+                self._logger.error(termcolor.colored(statement, "red"))
                 e = ValueError(e.orig)
                 e.__cause__ = None
                 raise e

From 42482f9f9290501f45a6ff7552895d4fdef24e16 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Mon, 6 Jun 2022 09:31:01 -0400
Subject: [PATCH 04/44] logging bytes-abbreviated _statement instead

---
 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 cd631fc..c6cdc4b 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -384,7 +384,7 @@ def teardown_appcontext(exception):
 
             # If constraint violated
             except sqlalchemy.exc.IntegrityError as e:
-                self._logger.error(termcolor.colored(statement, "red"))
+                self._logger.error(termcolor.colored(_statement, "red"))
                 e = ValueError(e.orig)
                 e.__cause__ = None
                 raise e
@@ -392,7 +392,7 @@ def teardown_appcontext(exception):
             # If user error
             except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.ProgrammingError) as e:
                 self._disconnect()
-                self._logger.error(termcolor.colored(statement, "red"))
+                self._logger.error(termcolor.colored(_statement, "red"))
                 e = RuntimeError(e.orig)
                 e.__cause__ = None
                 raise e

From a43b565b6ae9199d062336edbc670fbb7b184fbc Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Mon, 6 Jun 2022 21:34:00 -0400
Subject: [PATCH 05/44] scoping INFO and ERROR to FLASK_ENV=development

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

diff --git a/setup.py b/setup.py
index 0eb27bf..765213a 100644
--- a/setup.py
+++ b/setup.py
@@ -16,5 +16,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.1.0"
+    version="9.2.0"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index c6cdc4b..35c180e 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -9,6 +9,7 @@ def _enable_logging(f):
 
     import logging
     import functools
+    import os
 
     @functools.wraps(f)
     def decorator(*args, **kwargs):
@@ -19,9 +20,9 @@ def decorator(*args, **kwargs):
         except ModuleNotFoundError:
             return f(*args, **kwargs)
 
-        # Enable logging
+        # Enable logging in development mode
         disabled = logging.getLogger("cs50").disabled
-        if flask.current_app:
+        if flask.current_app and os.getenv("FLASK_ENV") == "development":
             logging.getLogger("cs50").disabled = False
         try:
             return f(*args, **kwargs)

From a6ceac6f47e5c77fc814b35ebcf66e1de9ce9cc8 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Tue, 14 Jun 2022 20:18:26 -0400
Subject: [PATCH 06/44] fixed support for None as NULL

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

diff --git a/setup.py b/setup.py
index 765213a..f0edaaf 100644
--- a/setup.py
+++ b/setup.py
@@ -16,5 +16,5 @@
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.2.0"
+    version="9.2.1"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 35c180e..f008b6f 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -476,7 +476,7 @@ def __escape(value):
             elif value is None:
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Keyword,
-                    sqlalchemy.types.NullType().literal_processor(self._engine.dialect)(value))
+                    sqlalchemy.null())
 
             # Unsupported value
             else:
diff --git a/tests/foo.py b/tests/foo.py
index f3955fc..2cf74e9 100644
--- a/tests/foo.py
+++ b/tests/foo.py
@@ -10,13 +10,15 @@
 logging.getLogger("cs50").disabled = False
 logging.getLogger("cs50").setLevel(logging.ERROR)
 
-db.execute("CREATE TABLE IF NOT EXISTS bar (firstname STRING UNIQUE)")
+db.execute("DROP TABLE IF EXISTS bar")
+db.execute("CREATE TABLE bar (firstname STRING UNIQUE)")
 
-db.execute("INSERT INTO bar VALUES (?)", "baz")
+db.execute("INSERT INTO bar VALUES (?)", None)
 db.execute("INSERT INTO bar VALUES (?)", "baz")
 db.execute("INSERT INTO bar VALUES (?)", "qux")
 db.execute("SELECT * FROM bar WHERE firstname IN (?)", ("baz", "qux"))
-db.execute("DELETE FROM bar")
+print(db.execute("SELECT * FROM bar"))
+#db.execute("DELETE FROM bar")
 
 """
 

From 0039f7e2ba76f561ac5eaf93d4f63d160c660464 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Tue, 14 Jun 2022 20:26:16 -0400
Subject: [PATCH 07/44] fixed license

---
 setup.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/setup.py b/setup.py
index f0edaaf..0c3f650 100644
--- a/setup.py
+++ b/setup.py
@@ -12,9 +12,11 @@
     description="CS50 library for Python",
     install_requires=["Flask>=1.0", "SQLAlchemy", "sqlparse", "termcolor", "wheel"],
     keywords="cs50",
+    license="GPLv3",
+    long_description_content_type="text/markdown",
     name="cs50",
     package_dir={"": "src"},
     packages=["cs50"],
     url="https://github.com/cs50/python-cs50",
-    version="9.2.1"
+    version="9.2.2"
 )

From 7448bc073000c1e3a9f0cd1e690eb45e4ad9537e Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Sat, 16 Jul 2022 15:50:55 -0400
Subject: [PATCH 08/44] added github release automation to workflow

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

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index e32f995..b0f91a9 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -28,18 +28,58 @@ jobs:
       run: |
         pip install .
         pip install mysqlclient psycopg2-binary
+
     - name: Run tests
       run: python tests/sql.py
       env:
         MYSQL_HOST: 127.0.0.1
         POSTGRESQL_HOST: 127.0.0.1
+
     - name: Install pypa/build
       run: python -m pip install build --user
+
     - name: Build a binary wheel and a source tarball
       run: python -m build --sdist --wheel --outdir dist/ .
+
     - name: Deploy to PyPI
       if: ${{ github.ref == 'refs/heads/main' }}
       uses: pypa/gh-action-pypi-publish@release/v1
       with:
         user: __token__
         password: ${{ secrets.PYPI_API_TOKEN }}
+
+    - name: Get Version
+      id: py_version
+      run: |
+        echo ::set-output name=version::$(python3 setup.py --version)
+
+    - name: Create Tag
+      uses: actions/github-script@v6
+      with:
+        github-token: ${{ github.token }}
+        script: |
+          try {
+            await github.rest.git.updateRef({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              ref: "tags/v${{ steps.py_version.outputs.version }}",
+              sha: context.sha,
+              force: true
+            })
+          } catch (e) {
+            await github.rest.git.createRef({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              ref: "refs/tags/v${{ steps.py_version.outputs.version }}",
+              sha: context.sha
+            })
+          }
+
+    - name: Create Release
+      run: |
+        curl \
+          -X POST \
+          -H "Accept: application/vnd.github+json" \
+          -H "Authorization: token ${{ secrets.GH_RELEASE_TOKEN }}" \
+          https://api.github.com/repos/${GITHUB_REPOSITORY}/releases \
+          -d '{"tag_name":"v${{ steps.py_version.outputs.version }}","target_commitish":"${{ github.sha }}","name":"v${{ steps.py_version.outputs.version }}","body":"${{ github.event.head_commit.message }}","draft":false,"prerelease":false,"generate_release_notes":false}'

From 23531820daf415732e07f39ce546f173e1da311a Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Thu, 1 Sep 2022 13:19:58 -0400
Subject: [PATCH 09/44] use v3 actions for checkout and setup-python

---
 .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 b0f91a9..dd919ef 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -20,8 +20,8 @@ jobs:
         ports:
         - 5432:5432
     steps:
-    - uses: actions/checkout@v2
-    - uses: actions/setup-python@v2
+    - uses: actions/checkout@v3
+    - uses: actions/setup-python@v3
       with:
         python-version: '3.6'
     - name: Setup databases

From 07ad96b72376ece9a6abeb7172ea0650b89b9580 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Sat, 22 Oct 2022 09:15:54 -0400
Subject: [PATCH 10/44] remove travis badge

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

diff --git a/README.md b/README.md
index cf2c62d..c94bd1f 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,5 @@
 # CS50 Library for Python
 
-[![Build Status](https://travis-ci.com/cs50/python-cs50.svg?branch=master)](https://travis-ci.org/cs50/python-cs50)
-
 ## Installation
 
 ```

From fcc68a16e7eaed3ec6899a487d18d01410ce8a0a Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Sat, 19 Nov 2022 13:52:16 -0500
Subject: [PATCH 11/44] simplified release process

---
 .github/workflows/main.yml | 33 +++++++--------------------------
 1 file changed, 7 insertions(+), 26 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index dd919ef..2059c50 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -53,33 +53,14 @@ jobs:
       run: |
         echo ::set-output name=version::$(python3 setup.py --version)
 
-    - name: Create Tag
+    - name: Create Release
       uses: actions/github-script@v6
       with:
         github-token: ${{ github.token }}
         script: |
-          try {
-            await github.rest.git.updateRef({
-              owner: context.repo.owner,
-              repo: context.repo.repo,
-              ref: "tags/v${{ steps.py_version.outputs.version }}",
-              sha: context.sha,
-              force: true
-            })
-          } catch (e) {
-            await github.rest.git.createRef({
-              owner: context.repo.owner,
-              repo: context.repo.repo,
-              ref: "refs/tags/v${{ steps.py_version.outputs.version }}",
-              sha: context.sha
-            })
-          }
-
-    - name: Create Release
-      run: |
-        curl \
-          -X POST \
-          -H "Accept: application/vnd.github+json" \
-          -H "Authorization: token ${{ secrets.GH_RELEASE_TOKEN }}" \
-          https://api.github.com/repos/${GITHUB_REPOSITORY}/releases \
-          -d '{"tag_name":"v${{ steps.py_version.outputs.version }}","target_commitish":"${{ github.sha }}","name":"v${{ steps.py_version.outputs.version }}","body":"${{ github.event.head_commit.message }}","draft":false,"prerelease":false,"generate_release_notes":false}'
+          github.rest.repos.createRelease({
+            owner: context.repo.owner,
+            repo: context.repo.repo,
+            tag_name: "v${{ steps.py_version.outputs.version }}",
+            tag_commitish: "${{ github.sha }}"
+          })

From b048ba21a2ad22058bac00f42be43b61f7fc744b Mon Sep 17 00:00:00 2001
From: Matthias Wenz <matthiaswenz@github.com>
Date: Fri, 2 Dec 2022 00:25:51 +0100
Subject: [PATCH 12/44] Load sqlite module if sqlite connection

---
 src/cs50/sql.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index f008b6f..c5b2d94 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -51,12 +51,12 @@ def __init__(self, url, **kwargs):
         import re
         import sqlalchemy
         import sqlalchemy.orm
-        import sqlite3
         import threading
 
         # Require that file already exist for SQLite
         matches = re.search(r"^sqlite:///(.+)$", url)
         if matches:
+            import sqlite3
             if not os.path.exists(matches.group(1)):
                 raise RuntimeError("does not exist: {}".format(matches.group(1)))
             if not os.path.isfile(matches.group(1)):

From bd25298787cb55772f0a615eab5da8bbfc035683 Mon Sep 17 00:00:00 2001
From: Matthias Wenz <matthiaswenz@github.com>
Date: Fri, 2 Dec 2022 16:57:01 +0100
Subject: [PATCH 13/44] Verify module loaded when accessing contents

---
 src/cs50/sql.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index c5b2d94..6611e49 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -74,7 +74,7 @@ def __init__(self, url, **kwargs):
         def connect(dbapi_connection, connection_record):
 
             # Enable foreign key constraints
-            if type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
+            if 'sqlite3' in sys.modules and type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
                 cursor = dbapi_connection.cursor()
                 cursor.execute("PRAGMA foreign_keys=ON")
                 cursor.close()

From 01236bcbc85191a7ed623f57d4c287e5ffb831e7 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Fri, 2 Dec 2022 14:41:20 -0500
Subject: [PATCH 14/44] import sys module, tweaked styles

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

diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 6611e49..1d33edb 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -1,3 +1,4 @@
+import sys
 import threading
 
 # Thread-local data
@@ -74,7 +75,7 @@ def __init__(self, url, **kwargs):
         def connect(dbapi_connection, connection_record):
 
             # Enable foreign key constraints
-            if 'sqlite3' in sys.modules and type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
+            if "sqlite3" in sys.modules and type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
                 cursor = dbapi_connection.cursor()
                 cursor.execute("PRAGMA foreign_keys=ON")
                 cursor.close()

From a3784cc827f2fe616538131b8ce5f00357d95625 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Fri, 2 Dec 2022 14:46:06 -0500
Subject: [PATCH 15/44] bump version number

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

diff --git a/setup.py b/setup.py
index 0c3f650..2d90788 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.2.2"
+    version="9.2.3"
 )

From 328a76c0b5af368ecba32005c6bfba2afd23daa4 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Fri, 2 Dec 2022 14:51:48 -0500
Subject: [PATCH 16/44] update setup-python 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 2059c50..26e072d 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -21,7 +21,7 @@ jobs:
         - 5432:5432
     steps:
     - uses: actions/checkout@v3
-    - uses: actions/setup-python@v3
+    - uses: actions/setup-python@v4
       with:
         python-version: '3.6'
     - name: Setup databases

From 0c552d363b2b6122484a056615800244f0026824 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Fri, 2 Dec 2022 15:22:59 -0500
Subject: [PATCH 17/44] use try-catch import sqlite3, update action

---
 .github/workflows/main.yml | 4 +++-
 src/cs50/sql.py            | 9 +++++++--
 2 files changed, 10 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 26e072d..f438d7b 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -23,7 +23,8 @@ jobs:
     - uses: actions/checkout@v3
     - uses: actions/setup-python@v4
       with:
-        python-version: '3.6'
+        python-version: '3.7'
+        check-latest: true
     - name: Setup databases
       run: |
         pip install .
@@ -54,6 +55,7 @@ jobs:
         echo ::set-output name=version::$(python3 setup.py --version)
 
     - name: Create Release
+      if: ${{ github.ref == 'refs/heads/main' }}
       uses: actions/github-script@v6
       with:
         github-token: ${{ github.token }}
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 1d33edb..8087657 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -53,11 +53,16 @@ def __init__(self, url, **kwargs):
         import sqlalchemy
         import sqlalchemy.orm
         import threading
+        
+        # Temporary fix for missing sqlite3 module on the buildpack stack
+        try:
+            import sqlite3
+        except:
+            pass
 
         # Require that file already exist for SQLite
         matches = re.search(r"^sqlite:///(.+)$", url)
         if matches:
-            import sqlite3
             if not os.path.exists(matches.group(1)):
                 raise RuntimeError("does not exist: {}".format(matches.group(1)))
             if not os.path.isfile(matches.group(1)):
@@ -75,7 +80,7 @@ def __init__(self, url, **kwargs):
         def connect(dbapi_connection, connection_record):
 
             # Enable foreign key constraints
-            if "sqlite3" in sys.modules and type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
+            if type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
                 cursor = dbapi_connection.cursor()
                 cursor.execute("PRAGMA foreign_keys=ON")
                 cursor.close()

From 3ccbd99c58d18df7e9385d421e85e8b7631bbe9d Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Fri, 2 Dec 2022 15:32:18 -0500
Subject: [PATCH 18/44] added try-catch workaround for SQL.connect when sqlite3
 is not available

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

diff --git a/setup.py b/setup.py
index 2d90788..62a7abe 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.2.3"
+    version="9.2.4"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 8087657..f2c090d 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -80,10 +80,14 @@ def __init__(self, url, **kwargs):
         def connect(dbapi_connection, connection_record):
 
             # Enable foreign key constraints
-            if type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
-                cursor = dbapi_connection.cursor()
-                cursor.execute("PRAGMA foreign_keys=ON")
-                cursor.close()
+            try:
+                if type(dbapi_connection) is sqlite3.Connection:  # If back end is sqlite
+                    cursor = dbapi_connection.cursor()
+                    cursor.execute("PRAGMA foreign_keys=ON")
+                    cursor.close()
+            except:
+                # Temporary fix for missing sqlite3 module on the buildpack stack
+                pass
 
         # Register listener
         sqlalchemy.event.listen(self._engine, "connect", connect)

From 777b4da5a1d2117b3959f72fbe74e4b50c2885de Mon Sep 17 00:00:00 2001
From: up-n-atom <adam.jaremko@gmail.com>
Date: Sat, 28 Jan 2023 23:26:06 -0500
Subject: [PATCH 19/44] Respect pep8 and revert 659c8f4

As described in pep8: "Object type comparisons should always use isinstance() instead of comparing types directly:"

Ref. https://peps.python.org/pep-0008/
---
 src/cs50/cs50.py |  2 +-
 src/cs50/sql.py  | 24 ++++++++++++------------
 2 files changed, 13 insertions(+), 13 deletions(-)

diff --git a/src/cs50/cs50.py b/src/cs50/cs50.py
index 1d7b6ea..16bfd0b 100644
--- a/src/cs50/cs50.py
+++ b/src/cs50/cs50.py
@@ -135,7 +135,7 @@ def get_string(prompt):
     as line endings. If user inputs only a line ending, returns "", not None.
     Returns None upon error or no input whatsoever (i.e., just EOF).
     """
-    if type(prompt) is not str:
+    if not isinstance(prompt, str):
         raise TypeError("prompt must be of type str")
     try:
         return input(prompt)
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index f2c090d..4f9457b 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -81,7 +81,7 @@ def connect(dbapi_connection, connection_record):
 
             # Enable foreign key constraints
             try:
-                if type(dbapi_connection) is 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()
@@ -350,11 +350,11 @@ def teardown_appcontext(exception):
 
                             # Coerce decimal.Decimal objects to float objects
                             # https://groups.google.com/d/msg/sqlalchemy/0qXMYJvq8SA/oqtvMD9Uw-kJ
-                            if type(row[column]) is decimal.Decimal:
+                            if isinstance(row[column], decimal.Decimal):
                                 row[column] = float(row[column])
 
                             # Coerce memoryview objects (as from PostgreSQL's bytea columns) to bytes
-                            elif type(row[column]) is memoryview:
+                            elif isinstance(row[column], memoryview):
                                 row[column] = bytes(row[column])
 
                     # Rows to be returned
@@ -432,13 +432,13 @@ def __escape(value):
             import sqlalchemy
 
             # bool
-            if type(value) is bool:
+            if isinstance(value, bool):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
                     sqlalchemy.types.Boolean().literal_processor(self._engine.dialect)(value))
 
             # bytes
-            elif type(value) is 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
                 elif self._engine.url.get_backend_name() == "postgresql":
@@ -447,37 +447,37 @@ def __escape(value):
                     raise RuntimeError("unsupported value: {}".format(value))
 
             # datetime.date
-            elif type(value) is 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")))
 
             # datetime.datetime
-            elif type(value) is datetime.datetime:
+            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")))
 
             # datetime.time
-            elif type(value) is 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")))
 
             # float
-            elif type(value) is float:
+            elif isinstance(value, float):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
                     sqlalchemy.types.Float().literal_processor(self._engine.dialect)(value))
 
             # int
-            elif type(value) is int:
+            elif isinstance(value, int):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.Number,
                     sqlalchemy.types.Integer().literal_processor(self._engine.dialect)(value))
 
             # str
-            elif type(value) is str:
+            elif isinstance(value, str):
                 return sqlparse.sql.Token(
                     sqlparse.tokens.String,
                     sqlalchemy.types.String().literal_processor(self._engine.dialect)(value))
@@ -493,7 +493,7 @@ def __escape(value):
                 raise RuntimeError("unsupported value: {}".format(value))
 
         # Escape value(s), separating with commas as needed
-        if type(value) in [list, tuple]:
+        if isinstance(value, (list, tuple)):
             return sqlparse.sql.TokenList(sqlparse.parse(", ".join([str(__escape(v)) for v in value])))
         else:
             return __escape(value)

From 6096c7e47aa75f18d9aa1f728538e33e37b763ba Mon Sep 17 00:00:00 2001
From: up-n-atom <adam.jaremko@gmail.com>
Date: Sun, 29 Jan 2023 03:00:43 -0500
Subject: [PATCH 20/44] Fix order of datetime type checks

datetime.datetime inherits datetime.date and will prematurely evaluate as an instance of datetime.date.
---
 src/cs50/sql.py | 12 ++++++------
 1 file changed, 6 insertions(+), 6 deletions(-)

diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 4f9457b..24690e3 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -446,18 +446,18 @@ def __escape(value):
                 else:
                     raise RuntimeError("unsupported value: {}".format(value))
 
-            # 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")))
-
             # datetime.datetime
             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")))
 
+            # 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")))
+
             # datetime.time
             elif isinstance(value, datetime.time):
                 return sqlparse.sql.Token(

From 53cf4d204f21d6c66f1b9ba6fe2055f0e6037feb Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Sun, 29 Jan 2023 16:58:03 +0800
Subject: [PATCH 21/44] bumped version number

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

diff --git a/setup.py b/setup.py
index 62a7abe..73b37a1 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.2.4"
+    version="9.2.5"
 )

From 464f237e32bc2f87affd34722a498054dc0d57f6 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Sun, 29 Jan 2023 18:39:42 +0800
Subject: [PATCH 22/44] fixated SQLAlchemy to version 1.4.46

---
 .github/workflows/main.yml | 2 +-
 .gitignore                 | 1 +
 setup.py                   | 2 +-
 3 files changed, 3 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index f438d7b..964db68 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -28,7 +28,7 @@ jobs:
     - name: Setup databases
       run: |
         pip install .
-        pip install mysqlclient psycopg2-binary
+        pip install mysqlclient psycopg2-binary SQLAlchemy==1.4.46
 
     - name: Run tests
       run: python tests/sql.py
diff --git a/.gitignore b/.gitignore
index 4286ed6..dd3ffcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 .*
 !/.github/
 !.gitignore
+build/
 *.db
 *.egg-info/
 *.pyc
diff --git a/setup.py b/setup.py
index 73b37a1..1a8ef3a 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", "SQLAlchemy", "sqlparse", "termcolor", "wheel"],
+    install_requires=["Flask>=1.0", "SQLAlchemy==1.4.46", "sqlparse", "termcolor", "wheel"],
     keywords="cs50",
     license="GPLv3",
     long_description_content_type="text/markdown",

From f807f1ef8b50b72d9307c0130828af41b99f517d Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Wed, 13 Sep 2023 11:10:54 -0400
Subject: [PATCH 23/44] added support for SQLAlchemy 2.0

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

diff --git a/setup.py b/setup.py
index 1a8ef3a..2c25e53 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", "SQLAlchemy==1.4.46", "sqlparse", "termcolor", "wheel"],
+    install_requires=["Flask>=1.0", "SQLAlchemy<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.2.5"
+    version="9.2.6"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 24690e3..8110cba 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -53,7 +53,7 @@ def __init__(self, url, **kwargs):
         import sqlalchemy
         import sqlalchemy.orm
         import threading
-        
+
         # Temporary fix for missing sqlite3 module on the buildpack stack
         try:
             import sqlite3
@@ -100,7 +100,7 @@ def connect(dbapi_connection, connection_record):
         self._logger.disabled = True
         try:
             connection = self._engine.connect()
-            connection.execute("SELECT 1")
+            connection.execute(sqlalchemy.text("SELECT 1"))
             connection.close()
         except sqlalchemy.exc.OperationalError as e:
             e = RuntimeError(_parse_exception(e))
@@ -344,7 +344,7 @@ def teardown_appcontext(exception):
                 if command == "SELECT":
 
                     # Coerce types
-                    rows = [dict(row) for row in result.fetchall()]
+                    rows = [dict(row) for row in result.mappings().all()]
                     for row in rows:
                         for column in row:
 

From ca09441d54ab52805baa38d50fe47c33bf4dcc65 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Wed, 13 Sep 2023 11:17:39 -0400
Subject: [PATCH 24/44] convert string to sqlalchemy text

---
 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 8110cba..527a98d 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -370,7 +370,7 @@ def teardown_appcontext(exception):
                         # "(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("""
+                        result = connection.execute(sqlalchemy.text("""
                             CREATE OR REPLACE FUNCTION _LASTVAL()
                             RETURNS integer LANGUAGE plpgsql
                             AS $$
@@ -382,7 +382,7 @@ def teardown_appcontext(exception):
                                 END;
                             END $$;
                             SELECT _LASTVAL();
-                        """)
+                        """))
                         ret = result.first()[0]
 
                     # If not PostgreSQL

From 2ed4803dbe6f29f0b10ddb1ef5b173d991d43805 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Thu, 14 Sep 2023 23:28:20 -0400
Subject: [PATCH 25/44] bump SQLAlchemy version to 1.4.49 in workflow

---
 .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 964db68..6547cf2 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -28,7 +28,7 @@ jobs:
     - name: Setup databases
       run: |
         pip install .
-        pip install mysqlclient psycopg2-binary SQLAlchemy==1.4.46
+        pip install mysqlclient psycopg2-binary SQLAlchemy==1.4.49
 
     - name: Run tests
       run: python tests/sql.py

From b8581fe410f95174edd555e428f32af8acb7bfd7 Mon Sep 17 00:00:00 2001
From: Aivar Annamaa <aivarannamaa@users.noreply.github.com>
Date: Sat, 23 Sep 2023 12:55:48 +0300
Subject: [PATCH 26/44] Fix method delegation in _flushfile

Required when the faked stream is already faked and the original fake also uses method delegation.
---
 src/cs50/cs50.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/cs50/cs50.py b/src/cs50/cs50.py
index 16bfd0b..425173c 100644
--- a/src/cs50/cs50.py
+++ b/src/cs50/cs50.py
@@ -49,7 +49,7 @@ def __init__(self, f):
         self.f = f
 
     def __getattr__(self, name):
-        return object.__getattribute__(self.f, name)
+        return getattr(self.f, name)
 
     def write(self, x):
         self.f.write(x)

From 4424e657431475114fc46cb86889ac08d2ce62a9 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Fri, 29 Sep 2023 16:07:48 -0400
Subject: [PATCH 27/44] added support for 'CREATE VIEW' statement

---
 setup.py        |  2 +-
 src/cs50/sql.py | 24 ++++++++++++++----------
 2 files changed, 15 insertions(+), 11 deletions(-)

diff --git a/setup.py b/setup.py
index 1a8ef3a..bd33a73 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.2.5"
+    version="9.2.6"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index 24690e3..8d07327 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -53,7 +53,7 @@ def __init__(self, url, **kwargs):
         import sqlalchemy
         import sqlalchemy.orm
         import threading
-        
+
         # Temporary fix for missing sqlite3 module on the buildpack stack
         try:
             import sqlite3
@@ -149,15 +149,15 @@ def execute(self, sql, *args, **kwargs):
         if len(args) > 0 and len(kwargs) > 0:
             raise RuntimeError("cannot pass both positional and named parameters")
 
-        # Infer command from (unflattened) statement
-        for token in statements[0]:
-            if token.ttype in [sqlparse.tokens.Keyword, sqlparse.tokens.Keyword.DDL, sqlparse.tokens.Keyword.DML]:
-                token_value = token.value.upper()
-                if token_value in ["BEGIN", "DELETE", "INSERT", "SELECT", "START", "UPDATE"]:
-                    command = token_value
-                    break
-        else:
-            command = None
+        # 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 = full_statement.upper()
+
+        # set of possible commands
+        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)
 
         # Flatten statement
         tokens = list(statements[0].flatten())
@@ -393,6 +393,10 @@ def teardown_appcontext(exception):
                 elif command in ["DELETE", "UPDATE"]:
                     ret = result.rowcount
 
+                # If CREATE VIEW, return True
+                elif command == "CREATE VIEW":
+                    ret = True
+
             # If constraint violated
             except sqlalchemy.exc.IntegrityError as e:
                 self._logger.error(termcolor.colored(_statement, "red"))

From 0608340a51d5fc60742b4a94ab3f9b45e7d45f2a Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Wed, 4 Oct 2023 11:29:20 -0700
Subject: [PATCH 28/44] unpin SQLAlchemy version in workflow

---
 .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 6547cf2..b8165f7 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -28,7 +28,7 @@ jobs:
     - name: Setup databases
       run: |
         pip install .
-        pip install mysqlclient psycopg2-binary SQLAlchemy==1.4.49
+        pip install mysqlclient psycopg2-binary SQLAlchemy
 
     - name: Run tests
       run: python tests/sql.py

From b3f0a0c5d0d5324595791f02bc3faae49cf1f15d Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Tue, 17 Oct 2023 16:00:07 -0400
Subject: [PATCH 29/44] replaced distutils with packaging

---
 setup.py          | 4 ++--
 src/cs50/cs50.py  | 1 -
 src/cs50/flask.py | 7 +++++--
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/setup.py b/setup.py
index bd33a73..d9a2ee4 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", "SQLAlchemy==1.4.46", "sqlparse", "termcolor", "wheel"],
+    install_requires=["Flask>=1.0", "packaging", "SQLAlchemy==1.4.46", "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.2.6"
+    version="9.2.7"
 )
diff --git a/src/cs50/cs50.py b/src/cs50/cs50.py
index 16bfd0b..31313f8 100644
--- a/src/cs50/cs50.py
+++ b/src/cs50/cs50.py
@@ -6,7 +6,6 @@
 import re
 import sys
 
-from distutils.sysconfig import get_python_lib
 from os.path import abspath, join
 from termcolor import colored
 from traceback import format_exception
diff --git a/src/cs50/flask.py b/src/cs50/flask.py
index 324ec30..3668007 100644
--- a/src/cs50/flask.py
+++ b/src/cs50/flask.py
@@ -6,10 +6,13 @@ def _wrap_flask(f):
     if f is None:
         return
 
-    from distutils.version import StrictVersion
+    from packaging.version import Version, InvalidVersion
     from .cs50 import _formatException
 
-    if f.__version__ < StrictVersion("1.0"):
+    try:
+        if Version(f.__version__) < Version("1.0"):
+            return
+    except InvalidVersion:
         return
 
     if os.getenv("CS50_IDE_TYPE") == "online":

From bb7263454280a73077ee1657956d030f6fd60f31 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Tue, 24 Oct 2023 09:36:58 -0400
Subject: [PATCH 30/44] bumped version number, fixed comment styles

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

diff --git a/setup.py b/setup.py
index d9a2ee4..8c1abfb 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.2.7"
+    version="9.3.0"
 )
diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index fee0d68..de3ad56 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -153,10 +153,10 @@ def execute(self, sql, *args, **kwargs):
         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
+        # Set of possible commands
         commands = {"BEGIN", "CREATE VIEW", "DELETE", "INSERT", "SELECT", "START", "UPDATE"}
 
-        # check if the full_statement starts with any command
+        # Check if the full_statement starts with any command
         command = next((cmd for cmd in commands if full_statement.startswith(cmd)), None)
 
         # Flatten statement

From 3ddef3132a2485d48ea487bb9401317921ebfc82 Mon Sep 17 00:00:00 2001
From: Rongxin Liu <rongxinliu.dev@gmail.com>
Date: Tue, 24 Oct 2023 09:39:36 -0400
Subject: [PATCH 31/44] updated SQLAlchemy version constraint

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

diff --git a/setup.py b/setup.py
index 8c1abfb..23f6b01 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==1.4.46", "sqlparse", "termcolor", "wheel"],
+    install_requires=["Flask>=1.0", "packaging", "SQLAlchemy<3", "sqlparse", "termcolor", "wheel"],
     keywords="cs50",
     license="GPLv3",
     long_description_content_type="text/markdown",

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 32/44] 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 e18486cc36f879c83eefc74a408ba411eeb35060 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 14:40:13 -0500
Subject: [PATCH 33/44] fixes #178

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

diff --git a/src/cs50/sql.py b/src/cs50/sql.py
index de3ad56..ef011da 100644
--- a/src/cs50/sql.py
+++ b/src/cs50/sql.py
@@ -399,6 +399,8 @@ def teardown_appcontext(exception):
 
             # If constraint violated
             except sqlalchemy.exc.IntegrityError as e:
+                if self._autocommit:
+                    connection.execute(sqlalchemy.text("ROLLBACK"))
                 self._logger.error(termcolor.colored(_statement, "red"))
                 e = ValueError(e.orig)
                 e.__cause__ = None

From 781c1c201090493a65793f586174e14644163f36 Mon Sep 17 00:00:00 2001
From: "David J. Malan" <malan@harvard.edu>
Date: Sun, 17 Dec 2023 14:40:26 -0500
Subject: [PATCH 34/44] increments version

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

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"
 )

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 35/44] 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 36/44] 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 37/44] 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 38/44] 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 39/44] 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 40/44] 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 41/44] 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 42/44] 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 43/44] 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 44/44] 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])