diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index c2ccec6..9de5690 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -62,5 +62,3 @@ jobs: path: dist/ - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml new file mode 100644 index 0000000..1e2c168 --- /dev/null +++ b/.github/workflows/test-python.yml @@ -0,0 +1,65 @@ +name: Python Tests + +on: + push: + branches: ["main"] + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash -eux {0} + +env: + MONGODB_VERSION: "7.0" + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ["ubuntu-latest", "macos-latest", "windows-latest"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + fail-fast: false + name: CPython ${{ matrix.python-version }}-${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Start MongoDB on Linux + if: ${{ startsWith(runner.os, 'Linux') }} + run: | + docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${MONGODB_VERSION} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 + until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do + sleep 1 + done + sudo docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" + - name: Start MongoDB on MacOS + if: ${{ startsWith(runner.os, 'macOS') }} + run: | + brew tap mongodb/brew + brew install mongodb/brew/mongodb-community@${MONGODB_VERSION} + brew services start mongodb-community@${MONGODB_VERSION} + - name: Start MongoDB on Windows + if: ${{ startsWith(runner.os, 'Windows') }} + shell: powershell + run: | + mkdir data + mongod --remove + mongod --install --dbpath=$(pwd)/data --logpath=$PWD/mongo.log + net start MongoDB + - name: Install package and pytest + run: | + python -m pip install . + python -m pip install pytest + - name: Run the tests + run: | + pytest . \ No newline at end of file diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..0fbdbd6 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,32 @@ +name: GitHub Actions Security Analysis with zizmor + +on: + push: + branches: ["main"] + pull_request: + branches: ["**"] + +jobs: + zizmor: + name: zizmor latest via Cargo + runs-on: ubuntu-latest + permissions: + security-events: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: Get zizmor + run: cargo install zizmor + - name: Run zizmor + run: zizmor --format sarif . > results.sarif + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif + category: zizmor diff --git a/README.md b/README.md index 2ea4f8f..da9b637 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Flask-PyMongo -PyMongo support for Flask applications +PyMongo support for Flask applications. Requires `flask>=3.0` and `pymongo>=4.0` ## Quickstart diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index f4826ce..0000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,40 +0,0 @@ -jobs: -- job: 'Style' - pool: - vmImage: 'ubuntu-latest' - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '3.9' - architecture: 'x64' - - script: | - pip install tox - tox -e style - displayName: 'tox -e style' - -- job: 'Test' - pool: - vmImage: 'ubuntu-latest' - strategy: - matrix: - Python38: - python.version: '3.8' - Python39: - python.version: '3.9' - Python310: - python.version: '3.10' - Python311: - python.version: '3.11' - maxParallel: 4 - - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - architecture: 'x64' - - - script: | - pip install wheel - pip install tox tox-docker - tox - displayName: 'tox' diff --git a/flask_pymongo/__init__.py b/flask_pymongo/__init__.py index 974202d..892649c 100644 --- a/flask_pymongo/__init__.py +++ b/flask_pymongo/__init__.py @@ -28,6 +28,7 @@ from functools import partial from mimetypes import guess_type +import hashlib from flask import abort, current_app, request from gridfs import GridFS, NoFile @@ -41,7 +42,7 @@ DriverInfo = None from flask_pymongo._version import __version__ -from flask_pymongo.helpers import BSONObjectIdConverter, JSONEncoder +from flask_pymongo.helpers import BSONObjectIdConverter, BSONProvider from flask_pymongo.wrappers import MongoClient DESCENDING = pymongo.DESCENDING @@ -65,10 +66,10 @@ class PyMongo(object): """ - def __init__(self, app=None, uri=None, json_options=None, *args, **kwargs): + def __init__(self, app=None, uri=None, *args, **kwargs): self.cx = None self.db = None - self._json_encoder = partial(JSONEncoder, json_options=json_options) + self._json_provider = BSONProvider(app) if app is not None: self.init_app(app, uri, *args, **kwargs) @@ -122,7 +123,7 @@ def init_app(self, app, uri=None, *args, **kwargs): self.db = self.cx[database_name] app.url_map.converters["ObjectId"] = BSONObjectIdConverter - app.json_encoder = self._json_encoder + app.json = self._json_provider # view helpers def send_file(self, filename, base="fs", version=-1, cache_for=31536000): @@ -163,14 +164,20 @@ def get_upload(filename): # mostly copied from flask/helpers.py, with # modifications for GridFS data = wrap_file(request.environ, fileobj, buffer_size=1024 * 255) + content_type, _ = guess_type(filename) response = current_app.response_class( data, - mimetype=fileobj.content_type, + mimetype=content_type, direct_passthrough=True, ) response.content_length = fileobj.length response.last_modified = fileobj.upload_date - response.set_etag(fileobj.md5) + # Compute the sha1 sum of the file for the etag. + pos = fileobj.tell() + raw_data = fileobj.read() + fileobj.seek(pos) + digest = hashlib.sha1(raw_data).hexdigest() + response.set_etag(digest) response.cache_control.max_age = cache_for response.cache_control.public = True response.make_conditional(request) diff --git a/flask_pymongo/helpers.py b/flask_pymongo/helpers.py index 617b6ed..8cdda23 100644 --- a/flask_pymongo/helpers.py +++ b/flask_pymongo/helpers.py @@ -24,21 +24,16 @@ # POSSIBILITY OF SUCH DAMAGE. -__all__ = ("BSONObjectIdConverter", "JSONEncoder") +__all__ = ("BSONObjectIdConverter", "BSONProvider") -from bson import json_util, SON +from bson import json_util from bson.errors import InvalidId from bson.objectid import ObjectId -from flask import abort, json as flask_json -from six import iteritems, string_types +from flask import abort +from flask.json.provider import JSONProvider from werkzeug.routing import BaseConverter import pymongo - -if pymongo.version_tuple >= (3, 5, 0): - from bson.json_util import RELAXED_JSON_OPTIONS - DEFAULT_JSON_OPTIONS = RELAXED_JSON_OPTIONS -else: - DEFAULT_JSON_OPTIONS = None +from bson.json_util import RELAXED_JSON_OPTIONS def _iteritems(obj): @@ -83,7 +78,7 @@ def to_url(self, value): return str(value) -class JSONEncoder(flask_json.JSONEncoder): +class BSONProvider(JSONProvider): """A JSON encoder that uses :mod:`bson.json_util` for MongoDB documents. @@ -101,54 +96,23 @@ def json_route(cart_id): differently than you expect. See :class:`~bson.json_util.JSONOptions` for details on the particular serialization that will be used. - A :class:`~flask_pymongo.helpers.JSONEncoder` is automatically + A :class:`~flask_pymongo.helpers.JSONProvider` is automatically automatically installed on the :class:`~flask_pymongo.PyMongo` instance at creation time, using - :const:`~bson.json_util.RELAXED_JSON_OPTIONS`. You can change the - :class:`~bson.json_util.JSONOptions` in use by passing - ``json_options`` to the :class:`~flask_pymongo.PyMongo` - constructor. - - .. note:: - - :class:`~bson.json_util.JSONOptions` is only supported as of - PyMongo version 3.4. For older versions of PyMongo, you will - have less control over the JSON format that results from calls - to :func:`~flask.json.jsonify`. - - .. versionadded:: 2.4.0 - + :const:`~bson.json_util.RELAXED_JSON_OPTIONS`. """ - def __init__(self, json_options, *args, **kwargs): - if json_options is None: - json_options = DEFAULT_JSON_OPTIONS - if json_options is not None: - self._default_kwargs = {"json_options": json_options} - else: - self._default_kwargs = {} + def __init__(self, app): + self._default_kwargs = {"json_options": RELAXED_JSON_OPTIONS} - super(JSONEncoder, self).__init__(*args, **kwargs) + super().__init__(app) - def default(self, obj): + def dumps(self, obj): """Serialize MongoDB object types using :mod:`bson.json_util`. + """ + return json_util.dumps(obj) - Falls back to Flask's default JSON serialization for all other types. - - This may raise ``TypeError`` for object types not recognized. - - .. versionadded:: 2.4.0 - + def loads(self, str_obj): + """Deserialize MongoDB object types using :mod:`bson.json_util`. """ - if hasattr(obj, "iteritems") or hasattr(obj, "items"): - return SON((k, self.default(v)) for k, v in iteritems(obj)) - elif hasattr(obj, "__iter__") and not isinstance(obj, string_types): - return [self.default(v) for v in obj] - else: - try: - return json_util.default(obj, **self._default_kwargs) - except TypeError: - # PyMongo couldn't convert into a serializable object, and - # the Flask default JSONEncoder won't; so we return the - # object itself and let stdlib json handle it if possible - return obj + return json_util.loads(str_obj) \ No newline at end of file diff --git a/flask_pymongo/tests/test_config.py b/flask_pymongo/tests/test_config.py index 9da8914..f2f5e5e 100644 --- a/flask_pymongo/tests/test_config.py +++ b/flask_pymongo/tests/test_config.py @@ -44,7 +44,7 @@ def test_config_with_uri_in_flask_conf_var(self): _wait_until_connected(mongo) assert mongo.db.name == self.dbname - assert ("localhost", self.port) == mongo.cx.address + assert ("localhost", self.port) == mongo.cx.address or ("127.0.0.1", self.port) == mongo.cx.address def test_config_with_uri_passed_directly(self): uri = "mongodb://localhost:{}/{}".format(self.port, self.dbname) @@ -53,7 +53,7 @@ def test_config_with_uri_passed_directly(self): _wait_until_connected(mongo) assert mongo.db.name == self.dbname - assert ("localhost", self.port) == mongo.cx.address + assert ("localhost", self.port) == mongo.cx.address or ("127.0.0.1", self.port) == mongo.cx.address def test_it_fails_with_no_uri(self): self.app.config.pop("MONGO_URI", None) diff --git a/flask_pymongo/tests/test_gridfs.py b/flask_pymongo/tests/test_gridfs.py index 5dc57da..ff742c5 100644 --- a/flask_pymongo/tests/test_gridfs.py +++ b/flask_pymongo/tests/test_gridfs.py @@ -1,4 +1,4 @@ -from hashlib import md5 +from hashlib import sha1 from io import BytesIO from bson.objectid import ObjectId @@ -30,15 +30,6 @@ def test_it_saves_files(self): gridfs = GridFS(self.mongo.db) assert gridfs.exists({"filename": "my-file"}) - def test_it_guesses_type_from_filename(self): - fileobj = BytesIO(b"these are the bytes") - - self.mongo.save_file("my-file.txt", fileobj) - - gridfs = GridFS(self.mongo.db) - gridfile = gridfs.find_one({"filename": "my-file.txt"}) - assert gridfile.content_type == "text/plain" - def test_it_saves_files_with_props(self): fileobj = BytesIO(b"these are the bytes") @@ -82,7 +73,7 @@ def test_it_sets_supports_conditional_gets(self): environ_args = { "method": "GET", "headers": { - "If-None-Match": md5(self.myfile.getvalue()).hexdigest(), + "If-None-Match": sha1(self.myfile.getvalue()).hexdigest(), }, } diff --git a/flask_pymongo/tests/test_json.py b/flask_pymongo/tests/test_json.py index 93d179d..4f506d9 100644 --- a/flask_pymongo/tests/test_json.py +++ b/flask_pymongo/tests/test_json.py @@ -2,7 +2,6 @@ from bson import ObjectId from flask import jsonify -from six import ensure_str from flask_pymongo.tests.util import FlaskPyMongoTest @@ -11,12 +10,12 @@ class JSONTest(FlaskPyMongoTest): def test_it_encodes_json(self): resp = jsonify({"foo": "bar"}) - dumped = json.loads(ensure_str(resp.get_data())) + dumped = json.loads(resp.get_data().decode('utf-8')) self.assertEqual(dumped, {"foo": "bar"}) def test_it_handles_pymongo_types(self): resp = jsonify({"id": ObjectId("5cf29abb5167a14c9e6e12c4")}) - dumped = json.loads(ensure_str(resp.get_data())) + dumped = json.loads(resp.get_data().decode('utf-8')) self.assertEqual(dumped, {"id": {"$oid": "5cf29abb5167a14c9e6e12c4"}}) def test_it_jsonifies_a_cursor(self): @@ -25,5 +24,5 @@ def test_it_jsonifies_a_cursor(self): curs = self.mongo.db.rows.find(projection={"_id": False}).sort("foo") resp = jsonify(curs) - dumped = json.loads(ensure_str(resp.get_data())) + dumped = json.loads(resp.get_data().decode('utf-8')) self.assertEqual([{"foo": "bar"}, {"foo": "baz"}], dumped) diff --git a/flask_pymongo/tests/test_wrappers.py b/flask_pymongo/tests/test_wrappers.py index 1bb334b..537887f 100644 --- a/flask_pymongo/tests/test_wrappers.py +++ b/flask_pymongo/tests/test_wrappers.py @@ -18,16 +18,3 @@ def test_find_one_or_404(self): # now it should not raise thing = self.mongo.db.things.find_one_or_404({"_id": "thing"}) assert thing["val"] == "foo", "got wrong thing" - - # also test with dotted-named collections - self.mongo.db.things.morethings.delete_many({}) - try: - self.mongo.db.things.morethings.find_one_or_404({"_id": "thing"}) - except HTTPException as notfound: - assert notfound.code == 404, "raised wrong exception" - - self.mongo.db.things.morethings.insert_one({"_id": "thing", "val": "foo"}) - - # now it should not raise - thing = self.mongo.db.things.morethings.find_one_or_404({"_id": "thing"}) - assert thing["val"] == "foo", "got wrong thing" diff --git a/flask_pymongo/tests/util.py b/flask_pymongo/tests/util.py index a6e7496..b35cd9c 100644 --- a/flask_pymongo/tests/util.py +++ b/flask_pymongo/tests/util.py @@ -1,4 +1,3 @@ -import os import unittest import flask @@ -6,34 +5,8 @@ import flask_pymongo -class ToxDockerMixin(object): - """ - Sets :attr:`port` based on the env var from tox-docker, if present. - """ - - def setUp(self): - super(ToxDockerMixin, self).setUp() - - # tox-docker could be running any version; find the env - # var that looks like what tox-docker would provide, but - # fail if there are more than one - env_vars = [ - (k, v) - for k, v in os.environ.items() - if k.startswith("MONGO") and k.endswith("_TCP_PORT") - ] - - self.port = 27017 - if len(env_vars) == 1: - self.port = int(env_vars[0][1]) - else: - self.fail( - f"too many tox-docker mongo port env vars (found {len(env_vars)})", - ) - - -class FlaskRequestTest(ToxDockerMixin, unittest.TestCase): +class FlaskRequestTest(unittest.TestCase): def setUp(self): super(FlaskRequestTest, self).setUp() @@ -42,6 +15,7 @@ def setUp(self): self.app = flask.Flask("test") self.context = self.app.test_request_context("/") self.context.push() + self.port = 27017 def tearDown(self): super(FlaskRequestTest, self).tearDown() diff --git a/setup.py b/setup.py index 539c7d9..4644b25 100644 --- a/setup.py +++ b/setup.py @@ -30,9 +30,8 @@ platforms="any", packages=find_packages(), install_requires=[ - "Flask>=1.0", - "PyMongo>=3.11", - "six", + "Flask>=3.0", + "PyMongo>=4.0", ], classifiers=[ "Environment :: Web Environment",