Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update to Django 5.1 #2264

Merged
merged 15 commits into from
Feb 6, 2025
Merged

Update to Django 5.1 #2264

merged 15 commits into from
Feb 6, 2025

Conversation

StevenMaude
Copy link
Contributor

@StevenMaude StevenMaude commented Dec 18, 2024

Fixes #2115.

This requires a workaround of the coding systems tests that use databases dynamically, due to changes in Django 5.1. We agreed in #2115 that working around the new restrictions was the best thing to do, to allow the upgrade without delay. In future, we may have to revisit this code and associated tests, either for future Django changes or to make maintenance and improvements easier.

TODO:

@StevenMaude
Copy link
Contributor Author

StevenMaude commented Dec 18, 2024

The issue here is with coding systems test failures, due to the way the tests use databases.

@StevenMaude StevenMaude added the deck-scrubbing Tech debt or other between-initiative tidy-up work label Dec 18, 2024
@StevenMaude
Copy link
Contributor Author

StevenMaude commented Jan 16, 2025

Legacy comment:

Current status of this:

* the working tests do pass, but are a bit of a mess still, so this PR does look somewhat horrendous right now, until we can get everything working, and then refine it
* the use of pytest fixtures, that those fixtures aren't easily available on classes when you're using unittest.TestCase and, for the base coding system tests, the time at which those fixtures are created, all makes this more complicated
* it's probably possible to move the general modified TestCase class out of each test module into a higher level, in most cases, and remove that duplication
* the import_data_path on the TestCase isn't always needed
* there's a bit of reviewing which pytest fixtures are being used (maybe the settings fixture is needed in places)
* from what I've looked at of the failing base tests, there's some other magic going on with fixtures (bnf_release and maybe others) that still means that the specified db_alias doesn't get picked up by the test, and we get the same DatabaseOperationForbidden error

@mikerkelly
Copy link
Contributor

mikerkelly commented Jan 20, 2025

Good progress so far, Steve. I think this is pretty close now! Quick comments, happy to discuss anything that's helpful...

fixtures aren't easily available on classes when you're using unittest.TestCase

@pytest.mark.usefixtures may help here? I think that's the intended approach for using fixtures in unittest/Django-style class-based test cases. Instead of the autouse=True fixtures in the draft base / test_import_data_utils.py commit.

  • it's probably possible to move the general modified TestCase class out of each test module into a higher level, in most cases, and remove that duplication

Would be very good to do this before we move on, as it's easiest to do now and will be useful if this is modified or extended at all in future. Could be a separate PR or even Issue if the base tests can be fixed up.

  • the import_data_path on the TestCase isn't always needed
  • there's a bit of reviewing which pytest fixtures are being used (maybe the settings fixture is needed in places)

Similar.

  • from what I've looked at of the failing base tests, there's some other magic going on with fixtures (bnf_release and maybe others) that still means that the specified db_alias doesn't get picked up by the test, and we get the same DatabaseOperationForbidden error

Could this issue be different-but-related to the issue in the others modules as this test case doesn't seem to be about dynamically creating a database? The exception stacktrace is related to the fixture creation, which does seem to do something dynamically with test case config, in setup_db. I think fixture code is called before the test setUp -- try putting a breakpoint in the setUp, it doesn't get reached. So the test cls.databases hasn't been updated by setUp by the time setup_db runs. So it's the same issue as before and again we don't have access to the test case class from inside setup_db. I guess we need to find a different way to access it, or, more likely, redesign the fixtures approach for this test module a bit. Maybe the work done in setup_db can be a function called in the setUp rather than a fixture, if that solves the sequencing issue?

@StevenMaude
Copy link
Contributor Author

NB: the tests ostensibly pass as of 37d1546, but that's because several are commented out, while I got one single coding system base test working.

@StevenMaude
Copy link
Contributor Author

StevenMaude commented Jan 22, 2025

There's one test left to fix, then I'll start thinking about how the debris can be tidied up.

Everything passes; time to clean up the mess.

@StevenMaude StevenMaude force-pushed the steve/django-5.1 branch 3 times, most recently from d837fbb to 01e44c6 Compare January 23, 2025 17:42
@StevenMaude
Copy link
Contributor Author

StevenMaude commented Jan 23, 2025

I'm now tidying this up on another branch and when done will push back to this PR branch. Now pushing back to this branch.

The stages there:

  • factor out the main TestCase class to override the database configuration we've created from the bnf tests to shared code
  • possibly subclass that new TestCase class for the base tests
  • use plain pytest-style assert instead of self.assert…
  • somehow figure out a way of generalising for CTV3 tests, or for the BNF such that the import data is introduced in the same way; probably better to just package the BNF data?
  • allow for multiple db_alias values
  • think about some better way of wrangling the database settings, to allow for multiple updates
  • review use of fixtures; can we use mark.usefixtures and reduce autouse? do we even need fixtures inside the class?
  • check handling of optional attributes like import_data_path and import_data_fixture
  • check which tests actually need the database_tmp_dir, and can we make it optional?
  • refactor the base tests if possible
  • should we bother generalising db_alias to accommodate the one case where multiple additional databases are used? (maybe not, it feels like a special case right now)
  • do we set the use of coding_systems_database_tmp_dir as optional, if possible
  • can we move out the expected_db_path from setUp?

@StevenMaude StevenMaude force-pushed the steve/django-5.1 branch 3 times, most recently from 92f13fd to f28f914 Compare January 28, 2025 20:20
@StevenMaude
Copy link
Contributor Author

Moving the messy branch with all the stages of progress to steve/django-5.1-fixup and the cleaned branch back here.

@StevenMaude StevenMaude force-pushed the steve/django-5.1 branch 3 times, most recently from 6d671cc to a3a0352 Compare January 30, 2025 17:52
@StevenMaude StevenMaude changed the title WIP: Update to Django 5.1 Update to Django 5.1 Jan 30, 2025
@StevenMaude StevenMaude marked this pull request as ready for review January 30, 2025 17:55
@StevenMaude StevenMaude force-pushed the steve/django-5.1 branch 2 times, most recently from 340142f to df3f4e1 Compare January 30, 2025 18:13
@StevenMaude
Copy link
Contributor Author

Local testing flagged up that logout didn't work in Django 5.1 (until I fixed it). That's another good case for "we should have automated end-to-end tests", as in #2135.

@StevenMaude StevenMaude force-pushed the steve/django-5.1 branch 2 times, most recently from eb7d79b to 17c39e4 Compare January 30, 2025 19:02
@StevenMaude StevenMaude force-pushed the steve/django-5.1 branch 2 times, most recently from 73b54ae to 199dca8 Compare February 5, 2025 18:50
@StevenMaude
Copy link
Contributor Author

StevenMaude commented Feb 5, 2025

Thanks to Mike and Jon for the helpful review. Based on their comments, I've done the following:

  • added another subclass of the DynamicDatabaseTestCase to cover the cases using the temporary path
  • moved some code comments into docstrings, and expanded on docstrings/comments
  • renamed some of the methods/attributes to be clearer
  • moved out the two staticmethod methods back to module-level
  • labelled some module/class-only functions/methods with a single leading underscore to indicate they are internal use
  • renamed the module containing the class from helpers.py to dynamic_db_classes.py

I've also:

  • upgraded again to Django 5.1.6 which was released 2025-02-06, and contains a couple of bug fixes
  • ran django-upgrade again, with no changes

The new commits are those following 4b400217002487253eff2366fd5b75ba8f9c522b. Earlier commit IDs have changed because I've rebased on newer main a couple of times.

I left these post-review fix commits deliberately separate, and small for easier review.

When the review is approved, I could squash these newer commits back to where the relevant code is changed in each case, to keep the history cleaner.

The previous commits reviewed typically made all the changes to a single file in a single commit (which makes logical sense as each Python module covers one aspect of the tests). That might also make sense once we've finalised these changes.

@StevenMaude StevenMaude force-pushed the steve/django-5.1 branch 2 times, most recently from 1a316f4 to e8dd5ed Compare February 5, 2025 19:00
Copy link
Contributor

@mikerkelly mikerkelly left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Steve. I think everything looks good now (one minor docs comment). Phew, good work! I think this is deployable, so approving.

It's up to you whether to rebase further or not.

@StevenMaude
Copy link
Contributor Author

I've squashed the subsequent post-review commits back in to where they would have gone, if I'd got it looking good the first time.

StevenMaude and others added 14 commits February 6, 2025 13:49
This changes `CheckConstraint.check` to `CheckConstraint.condition`.

See also opensafely-core/job-server@190a645
This results in a warning if not enabled, but will be the default
behaviour in Django 6. Therefore, enable the setting and suppress the
deprecation warning resulting from having that setting enabled (which
will be removed in Django 6).

See https://adamj.eu/tech/2023/12/07/django-fix-urlfield-assume-scheme-warnings/
Using a GET request for logout was seemingly deprecated in Django 5.0.

See opensafely-core/job-server#3995.
See #2115 for full details.

The problem this refactoring aims to solve is, in short, as quoted from
@mikerkelly in #2115:

> The [DatabaseOperationForbidden] errors occur because there's a mismatch
> between what [these test cases](https://github.com/opensafely-core/opencodelists/blob/main/coding_systems/bnf/tests/test_import_data.py)
> that import databases dynamically are
> trying to do and how both the Django and `pytest-django` test case
> frameworks work. In a multi-database context, the test frameworks want
> to declare which databases are available to a test case at
> test-case-definition-time, which then triggers those databases being
> created, the test case wrapped in a transaction referencing those
> databases, and the databases flushed in clean up. Since 5.1 it's
> enforced that test cases only open connections to such databases, to
> avoid accidental access. But the [coding system release import tests](https://github.com/opensafely-core/opencodelists/blob/main/coding_systems/bnf/tests/test_import_data.py)
> do want to access a database that they do not want to exist at the start
> of the test. That's in conflict. So maybe we shouldn't be using test cases
> this way at all?

Mike proposed several ideas to fix this that we looked at, and we found that:
amending the test case attribute that defines the allowed databases
seems reasonable, and, further, this was the only solution that we had working.

Altering this attribute is mentioned in Django's documentation:

https://docs.djangoproject.com/en/5.1/topics/testing/tools/#multi-database-support

It was decided that refactoring the existing tests was the solution for
now, rather than rethinking them or the code under test entirely. This
facilitates the upgrade to Django 5.1 without blocking us further. We
may have to revisit this code and these tests in future, with a view to
a more comprehensive overhaul.

The `DynamicDatabaseTestCase class facilitates getting most of the tests
working by just moving them into a test class that uses this
DynamicDatabaseTestCase class. Those tests have a generalisable pattern
for accessing the databases.

The tests that need refactoring fall into one of three general patterns:

* most tests that do not need a temporary coding system database
  directory, handled by the `DynamicDatabaseTestCase` class
* some tests that need a temporary coding system database directory,
  handled by the `DynamicDatabaseTestCaseWithTmpPath` class, which
  extends `DynamicDatabaseTestCase`
* a few edge cases that are discussed in subsequent commits; these will
  be worked around, rather than refactored in this series of commits
  (though they could be reworked in future):
  * tests in `coding_systems/base/tests/test_import_data_utils.py` that
    access the database in fixtures, before the test class' `setUp()`
    runs; these will be handled in that module specifically
  * tests in `coding_systems/bnf/tests/test_import_data.py` that uses a
    pytest fixture to produce import data, instead of fixtures stored in
    the repository, unlike the other tests

Co-authored-by: Mike Kelly <mike.kelly@thedatalab.org>
Note that this, like other commits to get these tests working, uses a
slightly unusual pattern of:

* having a fixture method that is primarily used to set an attribute on
  the test instance, with a fixture value
* marking that as a fixture to use with `usefixtures` on the test method

This is because of pytest's quirks, especially when using `TestCase`
classes, which don't seem as well supported as using test functions.

(However, we *need* the class because we're overriding Django's `TestCase`
behaviour.)

Specifically, to my understanding after experimenting and reading:

* `pytest.mark.usefixtures` can be used on regular methods, like a test
  method, but there's no way to access any fixture value that's returned
* only fixtures can call other fixtures, and we don't want the test
  method to be a fixture
These tests mostly fit the general `DynamicDatabaseTestCase` class.

One quirk for these tests is that they use an import data *fixture*
instead of a path to files, unlike the other tests that use import data.

Because of this, there's now a class to handle this case. All the
existing tests use the same fixture.
These tests are the most unconventional of the all the coding systems
tests that use the dynamic database because:

* they uses fixtures to setup databases, and this needs to happen before
  `setUp()`, so we also have to mangle the `databases` attribute before
  `setUp()`
* one of the tests actually needs to access multiple databases, which
  breaks the pattern for all other tests

After a little bit of trying, it seems overly complicated to try and
generalise the setup and teardown pattern here, partly due to the use of
pytest fixtures and the limitations in `TestCase` classes,
so it's been left as it was in the original code.
This gets converted back into a `Path` in `import_data()`, so this is a
simplification.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
deck-scrubbing Tech debt or other between-initiative tidy-up work
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Upgrade to Django v5.1.x
3 participants