Repository: ryanhiebert/django-cmd

Issue:
Enable runserver
`django runserver` starts a subprocess with the DJANGO_SETTINGS_MODULE set. It thankfully does go through the same script that started the command in the first place, but we need a reasonable proxy to detect that its happening, otherwise the automatically added path to the pythonpath won't be present in the auto-restarting runserver.

Reported on Discord.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/ryanhiebert/django-cmd/pull/12?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ryan+Hiebert) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`327d6df`)](https://app.codecov.io/gh/ryanhiebert/django-cmd/commit/327d6df655f4da840f1d8ef1207491cc76ddd62c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ryan+Hiebert) to head [(`a460796`)](https://app.codecov.io/gh/ryanhiebert/django-cmd/commit/a460796be7e0b7284252e6a846d3ae58771fc29b?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ryan+Hiebert).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main       #12   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            1         1           
  Lines           26        26           
  Branches         3         5    +2     
=========================================
  Hits            26        26           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ryanhiebert/django-cmd/pull/12?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ryan+Hiebert).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Ryan+Hiebert).


Patch:
diff --git a/HISTORY.rst b/HISTORY.rst
index cf2e197..63a3537 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -1,3 +1,11 @@
+2.2 (2024-12-19)
+++++++++++++++++
+
+* Add the directory to the Python path
+  if the settings module from the configuration file
+  is the same as it is in the environment.
+  This allows ``django runserver`` to work.
+
 2.1 (2024-12-16)
 ++++++++++++++++
 
diff --git a/django_cmd.py b/django_cmd.py
index ea4a29a..a66a1ae 100644
--- a/django_cmd.py
+++ b/django_cmd.py
@@ -1,6 +1,7 @@
 import configparser
 import os
 import sys
+from pathlib import Path
 
 try:
     import tomllib
@@ -13,24 +14,27 @@ from django.core.management import execute_from_command_line
 
 def main():
     """Run Django, getting the default from a file if needed."""
-    if "DJANGO_SETTINGS_MODULE" not in os.environ:
-        # Try loading configuration from pyproject.toml first
-        try:
-            with open("pyproject.toml", "rb") as f:
-                config = tomllib.load(f)
-            settings_module = config["tool"]["django"]["settings_module"]
-            os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
-            sys.path.insert(0, "")
-        except (FileNotFoundError, KeyError):
-            pass  # No pyproject.toml or no settings_module configured
-
-    if "DJANGO_SETTINGS_MODULE" not in os.environ:
+    settings_module = None
+
+    # Load from pyproject.toml first
+    pyproject = Path("pyproject.toml")
+    if pyproject.is_file():
+        with pyproject.open("rb") as f:
+            config = tomllib.load(f)
+            settings_module = (
+                config.get("tool", {}).get("django", {}).get("settings_module")
+            )
+
+    if settings_module is None:
         # Try loading configuration from setup.cfg next
         parser = configparser.RawConfigParser()
         parser.read("setup.cfg")
         if parser.has_option("django", "settings_module"):
             settings_module = parser.get("django", "settings_module")
-            os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
-            sys.path.insert(0, "")
+
+    if settings_module is not None:
+        os.environ.setdefault("DJANGO_SETTINGS_MODULE", settings_module)
+        if settings_module == os.environ["DJANGO_SETTINGS_MODULE"]:
+            sys.path.insert(0, os.getcwd())
 
     execute_from_command_line(sys.argv)
diff --git a/pyproject.toml b/pyproject.toml
index 00767f7..02ad0ae 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "django-cmd"
-version = "2.1"
+version = "2.2"
 description = "Have a django command"
 authors = [{ name = "Ryan Hiebert", email = "ryan@ryanhiebert.com" }]
 license = "MIT"
diff --git a/tox.ini b/tox.ini
index 32e016a..f6dd7cb 100644
--- a/tox.ini
+++ b/tox.ini
@@ -14,6 +14,7 @@ envlist =
 [testenv]
 setenv =
     COVERAGE_PROCESS_START=.coveragerc
+    TOX=True
 dependency_groups = dev
 deps =
     dj22: Django~=2.2.0


Test patch:
diff --git a/django_cmd_test.py b/django_cmd_test.py
index 69a6b22..dde64bd 100644
--- a/django_cmd_test.py
+++ b/django_cmd_test.py
@@ -1,9 +1,26 @@
 import os
 import subprocess
+from contextlib import contextmanager
+
+import pytest
 
 from django_cmd import main
 
 
+@contextmanager
+def restore_environ(keys):
+    """Restore the given environment keys after the context."""
+    missing = {key for key in keys if key not in os.environ}
+    saved = {key: os.environ[key] for key in keys if key in os.environ}
+    yield
+    for key in missing:
+        if key in os.environ:
+            del os.environ[key]
+    for key, value in saved.items():
+        os.environ[key] = value
+
+
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
 def test_main_passthru(monkeypatch, mocker, tmpdir):
     """It shouldn't change a given DJANGO_SETTINGS_MODULE."""
     cmd = mocker.patch("django_cmd.execute_from_command_line")
@@ -16,6 +33,7 @@ def test_main_passthru(monkeypatch, mocker, tmpdir):
     assert cmd.called
 
 
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
 def test_main_from_pyproject_toml(mocker, tmpdir):
     """Read settings module path from toml file."""
     cmd = mocker.patch("django_cmd.execute_from_command_line")
@@ -25,9 +43,9 @@ def test_main_from_pyproject_toml(mocker, tmpdir):
     main()
     assert os.environ.get("DJANGO_SETTINGS_MODULE") == "ball.yarn"
     assert cmd.called
-    del os.environ["DJANGO_SETTINGS_MODULE"]
 
 
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
 def test_main_from_pyproject_toml_nosetting(mocker, tmpdir):
     """Handle if there's a tool.django section with no settings module."""
     cmd = mocker.patch("django_cmd.execute_from_command_line")
@@ -39,6 +57,7 @@ def test_main_from_pyproject_toml_nosetting(mocker, tmpdir):
     assert cmd.called
 
 
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
 def test_main_from_pyproject_toml_nodjango(mocker, tmpdir):
     """Handle if there's no tool.django section."""
     cmd = mocker.patch("django_cmd.execute_from_command_line")
@@ -50,6 +69,7 @@ def test_main_from_pyproject_toml_nodjango(mocker, tmpdir):
     assert cmd.called
 
 
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
 def test_main_from_setup_cfg(mocker, tmpdir):
     """Read settings module path from config file."""
     cmd = mocker.patch("django_cmd.execute_from_command_line")
@@ -59,9 +79,9 @@ def test_main_from_setup_cfg(mocker, tmpdir):
     main()
     assert os.environ.get("DJANGO_SETTINGS_MODULE") == "ball.yarn"
     assert cmd.called
-    del os.environ["DJANGO_SETTINGS_MODULE"]
 
 
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
 def test_main_no_configfile(mocker, tmpdir):
     """Try to read settings module, but fail and still run command."""
     cmd = mocker.patch("django_cmd.execute_from_command_line")
@@ -71,6 +91,7 @@ def test_main_no_configfile(mocker, tmpdir):
     assert cmd.called
 
 
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
 def test_new_project(tmpdir):
     """Should be able to use with a new project."""
     tmpdir.chdir()
@@ -78,3 +99,24 @@ def test_new_project(tmpdir):
     config = '[tool.django]\nsettings_module = "myproject.settings"\n'
     tmpdir.join("pyproject.toml").write(config.encode("utf-8"))
     subprocess.run(["django", "check"], check=True)
+
+
+@pytest.mark.skipif(
+    os.environ.get("TOX"),
+    reason="Doesn't release the port quickly enough to run multiple times in quick succession with tox.",
+)
+@restore_environ(["DJANGO_SETTINGS_MODULE"])
+def test_runserver(tmpdir):
+    """Should be able to run the development server for several seconds."""
+    tmpdir.chdir()
+    subprocess.run(["django", "startproject", "myproject", "."], check=True)
+    config = '[tool.django]\nsettings_module = "myproject.settings"\n'
+    tmpdir.join("pyproject.toml").write(config.encode("utf-8"))
+    with pytest.raises(subprocess.TimeoutExpired):
+        # Runserver starts a subprocess, but never exits.
+        # 1 second is not enough time for it to start and error
+        # if the settings module isn't configured correctly.
+        # 2 seems to be OK, but to make it hopefully more reliable
+        # we'll use 3 seconds. Otherwise this might not break even
+        # if the functionality does.
+        subprocess.run(["django", "runserver"], check=True, timeout=3)