# Directory Structure
```
├── .gitignore
├── docker
│ └── entrypoint.sh
├── docker-commands.json
├── Dockerfile
├── migationsenv
│ ├── bin
│ │ ├── activate
│ │ ├── activate.csh
│ │ ├── activate.fish
│ │ ├── Activate.ps1
│ │ ├── coverage
│ │ ├── coverage-3.12
│ │ ├── coverage3
│ │ ├── django-admin
│ │ ├── dotenv
│ │ ├── httpx
│ │ ├── mcp
│ │ ├── pip
│ │ ├── pip3
│ │ ├── pip3.12
│ │ ├── py.test
│ │ ├── pytest
│ │ ├── python
│ │ ├── python3
│ │ ├── python3.12
│ │ ├── sqlformat
│ │ └── uvicorn
│ └── pyvenv.cfg
├── migrations_mcp
│ ├── __init__.py
│ ├── handlers
│ │ ├── __init__.py
│ │ └── utils.py
│ ├── service.py
│ └── tests
│ ├── __init__.py
│ └── test_handlers.py
├── pytest.ini
├── README.md
├── requirements.txt
├── setup.py
└── testproject
├── manage 2.py
├── manage.py
├── testapp
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── models.py
│ ├── tests
│ │ ├── __init__.py
│ │ ├── conftest.py
│ │ └── test_migrations.py
│ ├── tests.py
│ └── views.py
└── testproject
├── __init__.py
├── asgi.py
├── settings.py
├── urls.py
└── wsgi.py
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Python
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | build/
8 | develop-eggs/
9 | dist/
10 | downloads/
11 | eggs/
12 | .eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | wheels/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
22 | MANIFEST
23 |
24 | # Virtual Environment
25 | venv/
26 | env/
27 | ENV/
28 | .env
29 | .venv
30 | env.bak/
31 | venv.bak/
32 |
33 | # Django
34 | *.log
35 | local_settings.py
36 | db.sqlite3
37 | db.sqlite3-journal
38 | media/
39 | staticfiles/
40 |
41 | # IDE
42 | .idea/
43 | .vscode/
44 | *.swp
45 | *.swo
46 | *~
47 | .project
48 | .pydevproject
49 | .settings/
50 |
51 | # Testing
52 | .coverage
53 | .coverage.*
54 | .cache
55 | nosetests.xml
56 | coverage.xml
57 | *.cover
58 | .hypothesis/
59 | .pytest_cache/
60 | htmlcov/
61 |
62 | # Distribution / packaging
63 | .Python
64 | env/
65 | build/
66 | develop-eggs/
67 | dist/
68 | downloads/
69 | eggs/
70 | .eggs/
71 | lib/
72 | lib64/
73 | parts/
74 | sdist/
75 | var/
76 | wheels/
77 | *.egg-info/
78 | .installed.cfg
79 | *.egg
80 |
81 | # Docker
82 | .docker/
83 | docker-compose.override.yml
84 |
85 | # Logs
86 | logs/
87 | *.log
88 | npm-debug.log*
89 | yarn-debug.log*
90 | yarn-error.log*
91 |
92 | # System Files
93 | .DS_Store
94 | .DS_Store?
95 | ._*
96 | .Spotlight-V100
97 | .Trashes
98 | ehthumbs.db
99 | Thumbs.db
100 |
101 | # Environment Variables
102 | .env
103 | .env.local
104 | .env.*.local
105 |
106 | # Migration files (optional, uncomment if you want to ignore migrations)
107 | # */migrations/*.py
108 | # !*/migrations/__init__.py
109 |
110 | # Documentation
111 | /site
112 | docs/_build/
113 |
114 | # mypy
115 | .mypy_cache/
116 | .dmypy.json
117 | dmypy.json
118 |
119 | # Jupyter Notebook
120 | .ipynb_checkpoints
121 |
122 | # pyenv
123 | .python-version
124 |
125 | # celery
126 | celerybeat-schedule
127 | celerybeat.pid
128 |
129 | # SageMath parsed files
130 | *.sage.py
131 |
132 | # Spyder project settings
133 | .spyderproject
134 | .spyproject
135 |
136 | # Rope project settings
137 | .ropeproject
138 |
139 | # mkdocs documentation
140 | /site
141 |
142 | # Temporary files
143 | *.bak
144 | *.tmp
145 | *.temp
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | [](https://mseep.ai/app/mrrobotke-django-migrations-mcp)
2 |
3 | # Django Migrations MCP Service
4 |
5 | A Model Context Protocol (MCP) service for managing Django migrations in distributed environments. This service wraps Django's migration commands and exposes them as MCP endpoints, making it easy to manage migrations across multiple services and integrate with CI/CD pipelines.
6 |
7 | ## Features
8 |
9 | - Check migration status (equivalent to `showmigrations`)
10 | - Create new migrations with validation (equivalent to `makemigrations`)
11 | - Apply migrations with safety checks (equivalent to `migrate`)
12 | - Additional validation and safety checks:
13 | - Sequential migration order verification
14 | - Conflict detection
15 | - Dependency validation
16 | - Safety analysis of migration operations
17 |
18 | ## Installation
19 |
20 | ### Local Development
21 |
22 | 1. Clone the repository:
23 | ```bash
24 | git clone https://github.com/mrrobotke/django-migrations-mcp.git
25 | cd django-migrations-mcp
26 | ```
27 |
28 | 2. Install dependencies:
29 | ```bash
30 | pip install -r requirements.txt
31 | ```
32 |
33 | ## Configuration
34 |
35 | Set the following environment variables:
36 |
37 | ```bash
38 | export DJANGO_SETTINGS_MODULE="your_project.settings"
39 | export MCP_SERVICE_PORT=8000 # Optional, defaults to 8000
40 | ```
41 |
42 | ## Usage
43 |
44 | ### Running the Service
45 |
46 | 1. Directly with Python:
47 | ```bash
48 | python -m migrations_mcp.service
49 | ```
50 |
51 | 2. Using Docker:
52 | ```bash
53 | docker build -t django-migrations-mcp .
54 | docker run -e DJANGO_SETTINGS_MODULE=your_project.settings \
55 | -v /path/to/your/django/project:/app/project \
56 | -p 8000:8000 \
57 | django-migrations-mcp
58 | ```
59 |
60 | ### MCP Endpoints
61 |
62 | 1. Show Migrations:
63 | ```python
64 | from mcp import MCPClient
65 |
66 | client = MCPClient()
67 | migrations = await client.call("show_migrations")
68 | ```
69 |
70 | 2. Make Migrations:
71 | ```python
72 | result = await client.call("make_migrations", {
73 | "app_labels": ["myapp"], # Optional
74 | "dry_run": True # Optional
75 | })
76 | ```
77 |
78 | 3. Apply Migrations:
79 | ```python
80 | result = await client.call("migrate", {
81 | "app_label": "myapp", # Optional
82 | "migration_name": "0001", # Optional
83 | "fake": False, # Optional
84 | "plan": True # Optional
85 | })
86 | ```
87 |
88 | ## CI/CD Integration
89 |
90 | Example GitHub Actions workflow:
91 |
92 | ```yaml
93 | name: Django Migrations Check
94 |
95 | on:
96 | pull_request:
97 | paths:
98 | - '*/migrations/*.py'
99 | - '*/models.py'
100 |
101 | jobs:
102 | check-migrations:
103 | runs-on: ubuntu-latest
104 |
105 | steps:
106 | - uses: actions/checkout@v2
107 |
108 | - name: Set up Python
109 | uses: actions/setup-python@v2
110 | with:
111 | python-version: '3.11'
112 |
113 | - name: Install dependencies
114 | run: |
115 | pip install -r requirements.txt
116 |
117 | - name: Start MCP service
118 | run: |
119 | python -m migrations_mcp.service &
120 |
121 | - name: Check migrations
122 | run: |
123 | python ci/check_migrations.py
124 | ```
125 |
126 | Example check_migrations.py script:
127 |
128 | ```python
129 | import asyncio
130 | from mcp import MCPClient
131 |
132 | async def check_migrations():
133 | client = MCPClient()
134 |
135 | # Check current status
136 | migrations = await client.call("show_migrations")
137 |
138 | # Try making migrations
139 | result = await client.call("make_migrations", {"dry_run": True})
140 | if not result.success:
141 | print(f"Error: {result.message}")
142 | exit(1)
143 |
144 | print("Migration check passed!")
145 |
146 | if __name__ == "__main__":
147 | asyncio.run(check_migrations())
148 | ```
149 |
150 | ## Development
151 |
152 | ### Running Tests
153 |
154 | ```bash
155 | pytest migrations_mcp/tests/
156 | ```
157 |
158 | ### Code Style
159 |
160 | The project follows PEP 8 guidelines. Format your code using:
161 |
162 | ```bash
163 | black migrations_mcp/
164 | isort migrations_mcp/
165 | ```
166 |
167 | ## License
168 |
169 | MIT License. See LICENSE file for details.
170 |
171 | ## Contributing
172 |
173 | 1. Fork the repository
174 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
175 | 3. Commit your changes (`git commit -m 'Add amazing feature'`)
176 | 4. Push to the branch (`git push origin feature/amazing-feature`)
177 | 5. Open a Pull Request
178 |
179 | ## Docker Usage
180 |
181 | The project includes a `docker-commands.json` file that provides structured commands for different deployment scenarios. You can use these commands directly or parse them in your scripts.
182 |
183 | ### Available Docker Configurations
184 |
185 | 1. **Redis MCP Server**
186 | ```bash
187 | # Run Redis MCP server
188 | docker run -i --rm mcp/redis redis://host.docker.internal:6379
189 | ```
190 |
191 | 2. **Django Migrations MCP Server**
192 | ```bash
193 | # Basic setup
194 | docker run -d \
195 | --name django-migrations-mcp \
196 | -e DJANGO_SETTINGS_MODULE=your_project.settings \
197 | -e MCP_SERVICE_PORT=8000 \
198 | -v /path/to/your/django/project:/app/project \
199 | -p 8000:8000 \
200 | django-migrations-mcp
201 |
202 | # With Redis integration
203 | docker run -d \
204 | --name django-migrations-mcp \
205 | -e DJANGO_SETTINGS_MODULE=your_project.settings \
206 | -e MCP_SERVICE_PORT=8000 \
207 | -e REDIS_URL=redis://host.docker.internal:6379 \
208 | -v /path/to/your/django/project:/app/project \
209 | -p 8000:8000 \
210 | --network host \
211 | django-migrations-mcp
212 | ```
213 |
214 | 3. **Development Environment**
215 | ```bash
216 | # Using docker-compose
217 | docker-compose up -d --build
218 | ```
219 |
220 | 4. **Testing Environment**
221 | ```bash
222 | # Run tests in container
223 | docker run --rm \
224 | -e DJANGO_SETTINGS_MODULE=your_project.settings \
225 | -e PYTHONPATH=/app \
226 | -v ${PWD}:/app \
227 | django-migrations-mcp \
228 | pytest
229 | ```
230 |
231 | 5. **Production Environment**
232 | ```bash
233 | # Production setup with health check
234 | docker run -d \
235 | --name django-migrations-mcp \
236 | -e DJANGO_SETTINGS_MODULE=your_project.settings \
237 | -e MCP_SERVICE_PORT=8000 \
238 | -e REDIS_URL=redis://your-redis-host:6379 \
239 | -v /path/to/your/django/project:/app/project \
240 | -p 8000:8000 \
241 | --restart unless-stopped \
242 | --network your-network \
243 | django-migrations-mcp
244 | ```
245 |
246 | ### Using the Commands Programmatically
247 |
248 | You can parse and use the commands programmatically:
249 |
250 | ```python
251 | import json
252 | import subprocess
253 |
254 | # Load commands
255 | with open('docker-commands.json') as f:
256 | commands = json.load(f)
257 |
258 | # Run Redis MCP server
259 | redis_config = commands['mcpServers']['redis']
260 | subprocess.run([redis_config['command']] + redis_config['args'])
261 |
262 | # Run Django Migrations MCP server
263 | django_config = commands['mcpServers']['djangoMigrations']
264 | subprocess.run([django_config['command']] + django_config['args'])
265 | ```
266 |
267 | ### Network Setup
268 |
269 | 1. **Development Network**
270 | ```bash
271 | docker network create mcp-dev-network
272 | ```
273 |
274 | 2. **Production Network**
275 | ```bash
276 | docker network create --driver overlay --attachable mcp-prod-network
277 | ```
278 |
279 | ### Using MCP Tools
280 |
281 | The service exposes several endpoints that can be accessed via curl or any HTTP client:
282 |
283 | 1. **Show Migrations**
284 | ```bash
285 | curl -X POST http://localhost:8000/mcp \
286 | -H "Content-Type: application/json" \
287 | -d '{"method": "show_migrations"}'
288 | ```
289 |
290 | 2. **Make Migrations**
291 | ```bash
292 | curl -X POST http://localhost:8000/mcp \
293 | -H "Content-Type: application/json" \
294 | -d '{"method": "make_migrations", "params": {"apps": ["your_app"]}}'
295 | ```
296 |
297 | 3. **Apply Migrations**
298 | ```bash
299 | curl -X POST http://localhost:8000/mcp \
300 | -H "Content-Type: application/json" \
301 | -d '{"method": "migrate", "params": {"app": "your_app"}}'
302 | ```
```
--------------------------------------------------------------------------------
/migrations_mcp/handlers/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/migrations_mcp/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/testproject/testapp/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/testproject/testapp/migrations/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/testproject/testapp/tests/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/testproject/testproject/__init__.py:
--------------------------------------------------------------------------------
```python
1 |
```
--------------------------------------------------------------------------------
/testproject/testapp/tests.py:
--------------------------------------------------------------------------------
```python
1 | from django.test import TestCase
2 |
3 | # Create your tests here.
4 |
```
--------------------------------------------------------------------------------
/testproject/testapp/admin.py:
--------------------------------------------------------------------------------
```python
1 | from django.contrib import admin
2 |
3 | # Register your models here.
4 |
```
--------------------------------------------------------------------------------
/testproject/testapp/views.py:
--------------------------------------------------------------------------------
```python
1 | from django.shortcuts import render
2 |
3 | # Create your views here.
4 |
```
--------------------------------------------------------------------------------
/migrations_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Django migrations MCP package."""
2 |
3 | from .service import DjangoMigrationsMCP
4 |
5 | __all__ = ['DjangoMigrationsMCP']
6 |
```
--------------------------------------------------------------------------------
/testproject/testapp/apps.py:
--------------------------------------------------------------------------------
```python
1 | from django.apps import AppConfig
2 |
3 |
4 | class TestappConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "testapp"
7 |
```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
```
1 | Django>=5.1.0
2 | mcp
3 | python-dotenv>=1.0.0
4 | pydantic>=2.0.0
5 | structlog>=23.1.0
6 | pytest>=7.3.1
7 | pytest-asyncio>=0.21.0
8 | pytest-cov>=4.1.0
9 | black>=23.7.0
10 | isort>=5.12.0
11 | mypy>=1.4.1
12 | pylint>=2.17.5
```
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
```
1 | [pytest]
2 | DJANGO_SETTINGS_MODULE = testproject.settings
3 | python_paths = .
4 | testpaths = testproject/testapp/tests
5 | filterwarnings =
6 | ignore::DeprecationWarning
7 | ignore::UserWarning
8 | addopts = -v --tb=short
9 | asyncio_mode = auto
10 | asyncio_default_fixture_loop_scope = function
```
--------------------------------------------------------------------------------
/testproject/testapp/models.py:
--------------------------------------------------------------------------------
```python
1 | from django.db import models
2 |
3 | # Create your models here.
4 |
5 | class TestModel(models.Model):
6 | name = models.CharField(max_length=100)
7 | description = models.TextField(blank=True)
8 | created_at = models.DateTimeField(auto_now_add=True)
9 | updated_at = models.DateTimeField(auto_now=True)
10 |
11 | def __str__(self):
12 | return self.name
13 |
```
--------------------------------------------------------------------------------
/testproject/testproject/asgi.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | ASGI config for testproject project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings")
15 |
16 | application = get_asgi_application()
17 |
```
--------------------------------------------------------------------------------
/testproject/testproject/wsgi.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | WSGI config for testproject project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings")
15 |
16 | application = get_wsgi_application()
17 |
```
--------------------------------------------------------------------------------
/docker/entrypoint.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 | set -e
3 |
4 | # Validate environment variables
5 | if [ -z "$DJANGO_SETTINGS_MODULE" ]; then
6 | echo "Error: DJANGO_SETTINGS_MODULE environment variable is required"
7 | exit 1
8 | fi
9 |
10 | # Wait for database if needed (uncomment and modify as needed)
11 | # until nc -z $DB_HOST $DB_PORT; do
12 | # echo "Waiting for database..."
13 | # sleep 1
14 | # done
15 |
16 | # Start the MCP service
17 | exec python -m migrations_mcp.service
```
--------------------------------------------------------------------------------
/testproject/testapp/tests/conftest.py:
--------------------------------------------------------------------------------
```python
1 | """Test configuration for pytest."""
2 | import pytest
3 | from django.conf import settings
4 |
5 | @pytest.fixture(scope="function")
6 | def enable_db_access_for_all_tests(db):
7 | """Enable database access for all tests."""
8 | pass
9 |
10 | @pytest.fixture(scope="session")
11 | def django_db_setup():
12 | """Configure Django database for tests."""
13 | settings.DATABASES = {
14 | 'default': {
15 | 'ENGINE': 'django.db.backends.sqlite3',
16 | 'NAME': ':memory:',
17 | }
18 | }
```
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
```python
1 | from setuptools import setup, find_packages
2 |
3 | setup(
4 | name="migrations-mcp",
5 | version="0.1.0",
6 | packages=find_packages(),
7 | install_requires=[
8 | "Django>=5.1.0",
9 | "mcp",
10 | "python-dotenv>=1.0.0",
11 | "pydantic>=2.0.0",
12 | "structlog>=23.1.0",
13 | ],
14 | extras_require={
15 | "dev": [
16 | "pytest>=7.3.1",
17 | "pytest-asyncio>=0.21.0",
18 | "pytest-cov>=4.1.0",
19 | "black>=23.7.0",
20 | "isort>=5.12.0",
21 | "mypy>=1.4.1",
22 | "pylint>=2.17.5",
23 | ]
24 | },
25 | )
```
--------------------------------------------------------------------------------
/testproject/manage 2.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings")
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == "__main__":
22 | main()
23 |
```
--------------------------------------------------------------------------------
/testproject/manage.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testproject.settings")
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == "__main__":
22 | main()
23 |
```
--------------------------------------------------------------------------------
/testproject/testproject/urls.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | URL configuration for testproject project.
3 |
4 | The `urlpatterns` list routes URLs to views. For more information please see:
5 | https://docs.djangoproject.com/en/5.1/topics/http/urls/
6 | Examples:
7 | Function views
8 | 1. Add an import: from my_app import views
9 | 2. Add a URL to urlpatterns: path('', views.home, name='home')
10 | Class-based views
11 | 1. Add an import: from other_app.views import Home
12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
13 | Including another URLconf
14 | 1. Import the include() function: from django.urls import include, path
15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
16 | """
17 |
18 | from django.contrib import admin
19 | from django.urls import path
20 |
21 | urlpatterns = [
22 | path("admin/", admin.site.urls),
23 | ]
24 |
```
--------------------------------------------------------------------------------
/testproject/testapp/migrations/0001_initial.py:
--------------------------------------------------------------------------------
```python
1 | # Generated by Django 5.1.6 on 2025-02-11 21:07
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | initial = True
9 |
10 | dependencies = []
11 |
12 | operations = [
13 | migrations.CreateModel(
14 | name="TestModel",
15 | fields=[
16 | (
17 | "id",
18 | models.BigAutoField(
19 | auto_created=True,
20 | primary_key=True,
21 | serialize=False,
22 | verbose_name="ID",
23 | ),
24 | ),
25 | ("name", models.CharField(max_length=100)),
26 | ("description", models.TextField(blank=True)),
27 | ("created_at", models.DateTimeField(auto_now_add=True)),
28 | ("updated_at", models.DateTimeField(auto_now=True)),
29 | ],
30 | ),
31 | ]
32 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Build stage
2 | FROM python:3.11-slim as builder
3 |
4 | WORKDIR /app
5 |
6 | # Install build dependencies
7 | RUN apt-get update && apt-get install -y --no-install-recommends \
8 | build-essential \
9 | && rm -rf /var/lib/apt/lists/*
10 |
11 | # Install Python dependencies
12 | COPY requirements.txt .
13 | RUN pip wheel --no-cache-dir --no-deps --wheel-dir /app/wheels -r requirements.txt
14 |
15 | # Final stage
16 | FROM python:3.11-slim
17 |
18 | WORKDIR /app
19 |
20 | # Create non-root user
21 | RUN useradd -m -u 1000 mcp && \
22 | chown -R mcp:mcp /app
23 |
24 | # Copy wheels from builder
25 | COPY --from=builder /app/wheels /wheels
26 | COPY --from=builder /app/requirements.txt .
27 |
28 | # Install dependencies
29 | RUN pip install --no-cache /wheels/*
30 |
31 | # Copy application code
32 | COPY migrations_mcp ./migrations_mcp
33 | COPY docker/entrypoint.sh .
34 |
35 | # Set permissions
36 | RUN chmod +x entrypoint.sh && \
37 | chown -R mcp:mcp /app
38 |
39 | USER mcp
40 |
41 | # Environment variables
42 | ENV PYTHONPATH=/app
43 | ENV DJANGO_SETTINGS_MODULE=""
44 | ENV MCP_SERVICE_PORT=8000
45 |
46 | # Expose port
47 | EXPOSE 8000
48 |
49 | # Run the service
50 | ENTRYPOINT ["./entrypoint.sh"]
```
--------------------------------------------------------------------------------
/testproject/testapp/tests/test_migrations.py:
--------------------------------------------------------------------------------
```python
1 | """Test Django migrations with MCP service."""
2 | import os
3 | import pytest
4 | from migrations_mcp.service import DjangoMigrationsMCP
5 |
6 | # Configure Django settings for tests
7 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings')
8 |
9 | @pytest.fixture
10 | def service():
11 | """Create a DjangoMigrationsMCP service instance."""
12 | return DjangoMigrationsMCP()
13 |
14 | @pytest.mark.django_db
15 | @pytest.mark.asyncio
16 | async def test_show_migrations(service):
17 | """Test show_migrations command."""
18 | result = await service.show_migrations()
19 | assert isinstance(result, list)
20 | # At least one migration should exist (initial migration)
21 | assert len(result) > 0
22 |
23 | @pytest.mark.django_db
24 | @pytest.mark.asyncio
25 | async def test_make_migrations(service):
26 | """Test make_migrations command."""
27 | result = await service.make_migrations(['testapp'])
28 | assert result.success
29 | assert "Migrations created successfully" in result.message
30 |
31 | @pytest.mark.django_db
32 | @pytest.mark.asyncio
33 | async def test_migrate(service):
34 | """Test migrate command."""
35 | result = await service.migrate('testapp')
36 | assert result.success
37 | assert "Migrations applied successfully" in result.message
```
--------------------------------------------------------------------------------
/migationsenv/bin/activate.fish:
--------------------------------------------------------------------------------
```
1 | # This file must be used with "source <venv>/bin/activate.fish" *from fish*
2 | # (https://fishshell.com/). You cannot run it directly.
3 |
4 | function deactivate -d "Exit virtual environment and return to normal shell environment"
5 | # reset old environment variables
6 | if test -n "$_OLD_VIRTUAL_PATH"
7 | set -gx PATH $_OLD_VIRTUAL_PATH
8 | set -e _OLD_VIRTUAL_PATH
9 | end
10 | if test -n "$_OLD_VIRTUAL_PYTHONHOME"
11 | set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
12 | set -e _OLD_VIRTUAL_PYTHONHOME
13 | end
14 |
15 | if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
16 | set -e _OLD_FISH_PROMPT_OVERRIDE
17 | # prevents error when using nested fish instances (Issue #93858)
18 | if functions -q _old_fish_prompt
19 | functions -e fish_prompt
20 | functions -c _old_fish_prompt fish_prompt
21 | functions -e _old_fish_prompt
22 | end
23 | end
24 |
25 | set -e VIRTUAL_ENV
26 | set -e VIRTUAL_ENV_PROMPT
27 | if test "$argv[1]" != "nondestructive"
28 | # Self-destruct!
29 | functions -e deactivate
30 | end
31 | end
32 |
33 | # Unset irrelevant variables.
34 | deactivate nondestructive
35 |
36 | set -gx VIRTUAL_ENV "/Users/antonyngigge/Library/Mobile Documents/com~apple~CloudDocs/iworldafric/djangomigrationsmcp/migationsenv"
37 |
38 | set -gx _OLD_VIRTUAL_PATH $PATH
39 | set -gx PATH "$VIRTUAL_ENV/bin" $PATH
40 |
41 | # Unset PYTHONHOME if set.
42 | if set -q PYTHONHOME
43 | set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
44 | set -e PYTHONHOME
45 | end
46 |
47 | if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
48 | # fish uses a function instead of an env var to generate the prompt.
49 |
50 | # Save the current fish_prompt function as the function _old_fish_prompt.
51 | functions -c fish_prompt _old_fish_prompt
52 |
53 | # With the original prompt function renamed, we can override with our own.
54 | function fish_prompt
55 | # Save the return status of the last command.
56 | set -l old_status $status
57 |
58 | # Output the venv prompt; color taken from the blue of the Python logo.
59 | printf "%s%s%s" (set_color 4B8BBE) "(migationsenv) " (set_color normal)
60 |
61 | # Restore the return status of the previous command.
62 | echo "exit $old_status" | .
63 | # Output the original/"old" prompt.
64 | _old_fish_prompt
65 | end
66 |
67 | set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
68 | set -gx VIRTUAL_ENV_PROMPT "(migationsenv) "
69 | end
70 |
```
--------------------------------------------------------------------------------
/migrations_mcp/service.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Django Migrations MCP Service.
3 | This service provides endpoints for managing Django migrations through MCP.
4 | """
5 | import asyncio
6 | import logging
7 | from typing import Any, Dict, List, Optional
8 |
9 | from django.core.management import call_command
10 | from django.db import connection
11 | from mcp.server.fastmcp import FastMCP
12 | from mcp.server.fastmcp.tools.base import Tool
13 | from pydantic import BaseModel
14 | from asgiref.sync import sync_to_async
15 |
16 | # Configure logging
17 | logging.basicConfig(level=logging.INFO)
18 | logger = logging.getLogger(__name__)
19 |
20 | class MigrationStatus(BaseModel):
21 | """Model representing migration status."""
22 | app: str
23 | name: str
24 | applied: bool
25 | dependencies: List[str] = []
26 |
27 | class MigrationResult(BaseModel):
28 | """Model representing migration operation result."""
29 | success: bool
30 | message: str
31 | details: Optional[Dict[str, Any]] = None
32 |
33 | class DjangoMigrationsMCP(FastMCP):
34 | """MCP service for managing Django migrations."""
35 |
36 | async def show_migrations(self) -> List[str]:
37 | """Show all migrations."""
38 | try:
39 | @sync_to_async
40 | def _show_migrations():
41 | with connection.cursor() as cursor:
42 | call_command('showmigrations', stdout=cursor)
43 | return cursor.fetchall()
44 | return await _show_migrations()
45 | except Exception as e:
46 | return [f"Error showing migrations: {str(e)}"]
47 |
48 | show_migrations_tool = Tool.from_function(show_migrations)
49 |
50 | async def make_migrations(self, apps: Optional[List[str]] = None) -> MigrationResult:
51 | """Make migrations for specified apps or all apps."""
52 | try:
53 | @sync_to_async
54 | def _make_migrations():
55 | call_command('makemigrations', *apps if apps else [])
56 | await _make_migrations()
57 | return MigrationResult(
58 | success=True,
59 | message="Migrations created successfully"
60 | )
61 | except Exception as e:
62 | return MigrationResult(
63 | success=False,
64 | message=f"Error creating migrations: {str(e)}"
65 | )
66 |
67 | make_migrations_tool = Tool.from_function(make_migrations)
68 |
69 | async def migrate(self, app: Optional[str] = None) -> MigrationResult:
70 | """Apply migrations for specified app or all apps."""
71 | try:
72 | @sync_to_async
73 | def _migrate():
74 | call_command('migrate', app if app else '')
75 | await _migrate()
76 | return MigrationResult(
77 | success=True,
78 | message="Migrations applied successfully"
79 | )
80 | except Exception as e:
81 | return MigrationResult(
82 | success=False,
83 | message=f"Error applying migrations: {str(e)}"
84 | )
85 |
86 | migrate_tool = Tool.from_function(migrate)
87 |
88 | if __name__ == "__main__":
89 | service = DjangoMigrationsMCP()
90 | service.run()
```
--------------------------------------------------------------------------------
/testproject/testproject/settings.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Django settings for testproject project.
3 |
4 | Generated by 'django-admin startproject' using Django 5.1.5.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/5.1/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/5.1/ref/settings/
11 | """
12 |
13 | from pathlib import Path
14 |
15 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
16 | BASE_DIR = Path(__file__).resolve().parent.parent
17 |
18 |
19 | # Quick-start development settings - unsuitable for production
20 | # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
21 |
22 | # SECURITY WARNING: keep the secret key used in production secret!
23 | SECRET_KEY = 'django-insecure-test-key-for-development-only'
24 |
25 | # SECURITY WARNING: don't run with debug turned on in production!
26 | DEBUG = True
27 |
28 | ALLOWED_HOSTS = []
29 |
30 |
31 | # Application definition
32 |
33 | INSTALLED_APPS = [
34 | "django.contrib.admin",
35 | "django.contrib.auth",
36 | "django.contrib.contenttypes",
37 | "django.contrib.sessions",
38 | "django.contrib.messages",
39 | "django.contrib.staticfiles",
40 | "testapp", # Add our test app
41 | ]
42 |
43 | MIDDLEWARE = [
44 | "django.middleware.security.SecurityMiddleware",
45 | "django.contrib.sessions.middleware.SessionMiddleware",
46 | "django.middleware.common.CommonMiddleware",
47 | "django.middleware.csrf.CsrfViewMiddleware",
48 | "django.contrib.auth.middleware.AuthenticationMiddleware",
49 | "django.contrib.messages.middleware.MessageMiddleware",
50 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
51 | ]
52 |
53 | ROOT_URLCONF = "testproject.urls"
54 |
55 | TEMPLATES = [
56 | {
57 | "BACKEND": "django.template.backends.django.DjangoTemplates",
58 | "DIRS": [],
59 | "APP_DIRS": True,
60 | "OPTIONS": {
61 | "context_processors": [
62 | "django.template.context_processors.debug",
63 | "django.template.context_processors.request",
64 | "django.contrib.auth.context_processors.auth",
65 | "django.contrib.messages.context_processors.messages",
66 | ],
67 | },
68 | },
69 | ]
70 |
71 | WSGI_APPLICATION = "testproject.wsgi.application"
72 |
73 |
74 | # Database
75 | # https://docs.djangoproject.com/en/5.1/ref/settings/#databases
76 |
77 | DATABASES = {
78 | "default": {
79 | "ENGINE": "django.db.backends.sqlite3",
80 | "NAME": BASE_DIR / "db.sqlite3",
81 | }
82 | }
83 |
84 |
85 | # Password validation
86 | # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators
87 |
88 | AUTH_PASSWORD_VALIDATORS = [
89 | {
90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
91 | },
92 | {
93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
94 | },
95 | {
96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
97 | },
98 | {
99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
100 | },
101 | ]
102 |
103 |
104 | # Internationalization
105 | # https://docs.djangoproject.com/en/5.1/topics/i18n/
106 |
107 | LANGUAGE_CODE = "en-us"
108 |
109 | TIME_ZONE = "UTC"
110 |
111 | USE_I18N = True
112 |
113 | USE_TZ = True
114 |
115 |
116 | # Static files (CSS, JavaScript, Images)
117 | # https://docs.djangoproject.com/en/5.1/howto/static-files/
118 |
119 | STATIC_URL = "static/"
120 |
121 | # Default primary key field type
122 | # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
123 |
124 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
125 |
```
--------------------------------------------------------------------------------
/docker-commands.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "mcpServers": {
3 | "redis": {
4 | "command": "docker",
5 | "args": [
6 | "run",
7 | "-i",
8 | "--rm",
9 | "mcp/redis",
10 | "redis://host.docker.internal:6379"
11 | ]
12 | },
13 | "djangoMigrations": {
14 | "command": "docker",
15 | "args": [
16 | "run",
17 | "-d",
18 | "--name", "django-migrations-mcp",
19 | "-e", "DJANGO_SETTINGS_MODULE=your_project.settings",
20 | "-e", "MCP_SERVICE_PORT=8000",
21 | "-v", "/path/to/your/django/project:/app/project",
22 | "-p", "8000:8000",
23 | "django-migrations-mcp"
24 | ]
25 | },
26 | "djangoMigrationsWithRedis": {
27 | "command": "docker",
28 | "args": [
29 | "run",
30 | "-d",
31 | "--name", "django-migrations-mcp",
32 | "-e", "DJANGO_SETTINGS_MODULE=your_project.settings",
33 | "-e", "MCP_SERVICE_PORT=8000",
34 | "-e", "REDIS_URL=redis://host.docker.internal:6379",
35 | "-v", "/path/to/your/django/project:/app/project",
36 | "-p", "8000:8000",
37 | "--network", "host",
38 | "django-migrations-mcp"
39 | ]
40 | },
41 | "development": {
42 | "command": "docker-compose",
43 | "args": [
44 | "up",
45 | "-d",
46 | "--build"
47 | ],
48 | "environment": {
49 | "DJANGO_SETTINGS_MODULE": "your_project.settings",
50 | "MCP_SERVICE_PORT": "8000",
51 | "REDIS_URL": "redis://localhost:6379"
52 | }
53 | },
54 | "testing": {
55 | "command": "docker",
56 | "args": [
57 | "run",
58 | "--rm",
59 | "-e", "DJANGO_SETTINGS_MODULE=your_project.settings",
60 | "-e", "PYTHONPATH=/app",
61 | "-v", "${PWD}:/app",
62 | "django-migrations-mcp",
63 | "pytest"
64 | ]
65 | },
66 | "production": {
67 | "command": "docker",
68 | "args": [
69 | "run",
70 | "-d",
71 | "--name", "django-migrations-mcp",
72 | "-e", "DJANGO_SETTINGS_MODULE=your_project.settings",
73 | "-e", "MCP_SERVICE_PORT=8000",
74 | "-e", "REDIS_URL=redis://your-redis-host:6379",
75 | "-v", "/path/to/your/django/project:/app/project",
76 | "-p", "8000:8000",
77 | "--restart", "unless-stopped",
78 | "--network", "your-network",
79 | "django-migrations-mcp"
80 | ],
81 | "healthCheck": {
82 | "test": ["CMD", "curl", "-f", "http://localhost:8000/health"],
83 | "interval": "30s",
84 | "timeout": "10s",
85 | "retries": 3
86 | }
87 | }
88 | },
89 | "tools": {
90 | "showMigrations": {
91 | "command": "curl",
92 | "args": [
93 | "-X", "POST",
94 | "http://localhost:8000/mcp",
95 | "-H", "Content-Type: application/json",
96 | "-d", "{\"method\": \"show_migrations\"}"
97 | ]
98 | },
99 | "makeMigrations": {
100 | "command": "curl",
101 | "args": [
102 | "-X", "POST",
103 | "http://localhost:8000/mcp",
104 | "-H", "Content-Type: application/json",
105 | "-d", "{\"method\": \"make_migrations\", \"params\": {\"apps\": [\"your_app\"]}}"
106 | ]
107 | },
108 | "migrate": {
109 | "command": "curl",
110 | "args": [
111 | "-X", "POST",
112 | "http://localhost:8000/mcp",
113 | "-H", "Content-Type: application/json",
114 | "-d", "{\"method\": \"migrate\", \"params\": {\"app\": \"your_app\"}}"
115 | ]
116 | }
117 | },
118 | "networks": {
119 | "development": {
120 | "command": "docker",
121 | "args": [
122 | "network",
123 | "create",
124 | "mcp-dev-network"
125 | ]
126 | },
127 | "production": {
128 | "command": "docker",
129 | "args": [
130 | "network",
131 | "create",
132 | "--driver", "overlay",
133 | "--attachable",
134 | "mcp-prod-network"
135 | ]
136 | }
137 | }
138 | }
```
--------------------------------------------------------------------------------
/migrations_mcp/handlers/utils.py:
--------------------------------------------------------------------------------
```python
1 | """Utility functions for migration validation and checks."""
2 | import os
3 | import re
4 | from pathlib import Path
5 | from typing import Dict, List, Optional, Set, Tuple
6 |
7 | from django.apps import apps
8 | from django.db.migrations.loader import MigrationLoader
9 |
10 |
11 | def get_migration_files(app_label: str) -> List[str]:
12 | """Get all migration files for an app."""
13 | app_config = apps.get_app_config(app_label)
14 | migrations_dir = Path(app_config.path) / 'migrations'
15 |
16 | if not migrations_dir.exists():
17 | return []
18 |
19 | return [
20 | f.name for f in migrations_dir.iterdir()
21 | if f.is_file() and f.name.endswith('.py')
22 | and not f.name.startswith('__')
23 | ]
24 |
25 | def parse_migration_number(filename: str) -> Optional[int]:
26 | """Extract migration number from filename."""
27 | match = re.match(r'^(\d{4})_.*\.py$', filename)
28 | return int(match.group(1)) if match else None
29 |
30 | def check_sequential_order(app_label: str) -> Tuple[bool, List[str]]:
31 | """Check if migrations are in sequential order."""
32 | files = get_migration_files(app_label)
33 | numbers = [parse_migration_number(f) for f in files]
34 | numbers = [n for n in numbers if n is not None]
35 |
36 | if not numbers:
37 | return True, []
38 |
39 | expected = list(range(min(numbers), max(numbers) + 1))
40 | missing = set(expected) - set(numbers)
41 |
42 | if missing:
43 | return False, [
44 | f"Missing migration number(s): {', '.join(map(str, missing))}"
45 | ]
46 | return True, []
47 |
48 | def detect_conflicts(app_label: str) -> List[str]:
49 | """Detect migration conflicts."""
50 | loader = MigrationLoader(None)
51 | conflicts = []
52 |
53 | # Check for conflicts in the migration graph
54 | if loader.graph.conflicts:
55 | for app, nodes in loader.graph.conflicts.items():
56 | if app == app_label:
57 | conflicts.extend([
58 | f"Conflict in {app}: migrations {', '.join(nodes)}"
59 | ])
60 |
61 | return conflicts
62 |
63 | def validate_dependencies(app_label: str) -> List[str]:
64 | """Validate migration dependencies."""
65 | loader = MigrationLoader(None)
66 | errors = []
67 |
68 | for migration in loader.disk_migrations.values():
69 | if migration.app_label == app_label:
70 | for dep_app, dep_name in migration.dependencies:
71 | # Check if dependency exists
72 | if (dep_app, dep_name) not in loader.disk_migrations:
73 | errors.append(
74 | f"Missing dependency: {dep_app}.{dep_name} "
75 | f"required by {app_label}.{migration.name}"
76 | )
77 |
78 | return errors
79 |
80 | def get_migration_plan(app_label: Optional[str] = None) -> List[Tuple[str, bool]]:
81 | """Get the migration plan showing what needs to be applied."""
82 | from django.db import connections
83 | from django.db.migrations.executor import MigrationExecutor
84 |
85 | executor = MigrationExecutor(connections['default'])
86 | plan = executor.migration_plan(executor.loader.graph.leaf_nodes())
87 |
88 | if app_label:
89 | plan = [
90 | (migration, backwards)
91 | for migration, backwards in plan
92 | if migration.app_label == app_label
93 | ]
94 |
95 | return plan
96 |
97 | def check_migration_safety(
98 | app_label: str,
99 | migration_name: str
100 | ) -> Tuple[bool, List[str]]:
101 | """Check if a migration is safe to apply."""
102 | warnings = []
103 | is_safe = True
104 |
105 | # Load the migration
106 | loader = MigrationLoader(None)
107 | try:
108 | migration = loader.get_migration_by_prefix(app_label, migration_name)
109 | except KeyError:
110 | return False, ["Migration not found"]
111 |
112 | # Check for dangerous operations
113 | for operation in migration.operations:
114 | op_name = operation.__class__.__name__
115 |
116 | if op_name == 'DeleteModel':
117 | warnings.append(f"Warning: Migration deletes model {operation.name}")
118 | is_safe = False
119 |
120 | elif op_name == 'RemoveField':
121 | warnings.append(
122 | f"Warning: Migration removes field {operation.model_name}."
123 | f"{operation.name}"
124 | )
125 | is_safe = False
126 |
127 | elif op_name == 'AlterField':
128 | warnings.append(
129 | f"Warning: Migration alters field {operation.model_name}."
130 | f"{operation.name}"
131 | )
132 |
133 | return is_safe, warnings
```
--------------------------------------------------------------------------------
/migrations_mcp/tests/test_handlers.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for the Django Migrations MCP service."""
2 | import os
3 | import pytest
4 | from pathlib import Path
5 | from typing import List, Tuple
6 | from unittest.mock import AsyncMock, MagicMock, patch
7 |
8 | from django.apps import apps
9 | from django.core.management import call_command
10 | from django.db.migrations.loader import MigrationLoader
11 |
12 | from migrations_mcp.service import DjangoMigrationsMCP
13 | from migrations_mcp.handlers.utils import (
14 | check_sequential_order,
15 | detect_conflicts,
16 | validate_dependencies,
17 | check_migration_safety
18 | )
19 |
20 | @pytest.fixture
21 | def service():
22 | """Create a DjangoMigrationsMCP service instance."""
23 | return DjangoMigrationsMCP()
24 |
25 | @pytest.fixture
26 | def mock_app_config():
27 | """Mock Django app configuration."""
28 | mock = MagicMock()
29 | mock.path = str(Path(__file__).parent / 'test_migrations')
30 | return mock
31 |
32 | @pytest.fixture
33 | def mock_migration_loader():
34 | """Mock Django migration loader."""
35 | mock = MagicMock()
36 | mock.disk_migrations = {}
37 | mock.graph.conflicts = {}
38 | return mock
39 |
40 | @pytest.mark.asyncio
41 | async def test_show_migrations(service):
42 | """Test show_migrations handler."""
43 | with patch('django.core.management.call_command') as mock_call:
44 | mock_call.return_value = None
45 | result = await service.show_migrations()
46 | assert isinstance(result, list)
47 | mock_call.assert_called_once_with(
48 | 'showmigrations',
49 | list=True,
50 | _callback=pytest.ANY
51 | )
52 |
53 | @pytest.mark.asyncio
54 | async def test_make_migrations(service):
55 | """Test make_migrations handler."""
56 | with patch('django.core.management.call_command') as mock_call:
57 | mock_call.return_value = "Created migration"
58 | result = await service.make_migrations(
59 | app_labels=['testapp'],
60 | dry_run=True
61 | )
62 | assert result.success
63 | assert "successfully" in result.message
64 | mock_call.assert_called_once_with(
65 | 'makemigrations',
66 | 'testapp',
67 | dry_run=True,
68 | verbosity=2
69 | )
70 |
71 | @pytest.mark.asyncio
72 | async def test_migrate(service):
73 | """Test migrate handler."""
74 | with patch('django.core.management.call_command') as mock_call:
75 | mock_call.return_value = "Applied migration"
76 | result = await service.migrate(
77 | app_label='testapp',
78 | migration_name='0001',
79 | fake=True
80 | )
81 | assert result.success
82 | assert "successfully" in result.message
83 | mock_call.assert_called_once_with(
84 | 'migrate',
85 | 'testapp',
86 | '0001',
87 | fake=True,
88 | plan=False,
89 | verbosity=2
90 | )
91 |
92 | def test_check_sequential_order(mock_app_config):
93 | """Test migration sequential order checking."""
94 | with patch('django.apps.apps.get_app_config', return_value=mock_app_config):
95 | # Create test migration files
96 | migrations_dir = Path(mock_app_config.path)
97 | migrations_dir.mkdir(parents=True, exist_ok=True)
98 |
99 | # Create test migration files
100 | migrations = ['0001_initial.py', '0002_update.py', '0004_change.py']
101 | for migration in migrations:
102 | (migrations_dir / migration).touch()
103 |
104 | is_sequential, errors = check_sequential_order('testapp')
105 | assert not is_sequential
106 | assert any('Missing migration number(s): 3' in error for error in errors)
107 |
108 | # Cleanup
109 | for migration in migrations:
110 | (migrations_dir / migration).unlink()
111 | migrations_dir.rmdir()
112 |
113 | def test_detect_conflicts(mock_migration_loader):
114 | """Test migration conflict detection."""
115 | with patch('migrations_mcp.handlers.utils.MigrationLoader',
116 | return_value=mock_migration_loader):
117 | mock_migration_loader.graph.conflicts = {
118 | 'testapp': ['0001_initial', '0001_other']
119 | }
120 |
121 | conflicts = detect_conflicts('testapp')
122 | assert len(conflicts) == 1
123 | assert 'Conflict in testapp' in conflicts[0]
124 |
125 | def test_validate_dependencies(mock_migration_loader):
126 | """Test migration dependency validation."""
127 | with patch('migrations_mcp.handlers.utils.MigrationLoader',
128 | return_value=mock_migration_loader):
129 | # Mock a migration with missing dependency
130 | migration = MagicMock()
131 | migration.app_label = 'testapp'
132 | migration.name = '0001_initial'
133 | migration.dependencies = [('other_app', '0001_initial')]
134 |
135 | mock_migration_loader.disk_migrations = {
136 | ('testapp', '0001_initial'): migration
137 | }
138 |
139 | errors = validate_dependencies('testapp')
140 | assert len(errors) == 1
141 | assert 'Missing dependency' in errors[0]
142 |
143 | def test_check_migration_safety():
144 | """Test migration safety checking."""
145 | with patch('migrations_mcp.handlers.utils.MigrationLoader') as mock_loader:
146 | # Mock a migration with unsafe operations
147 | migration = MagicMock()
148 | delete_op = MagicMock()
149 | delete_op.__class__.__name__ = 'DeleteModel'
150 | delete_op.name = 'TestModel'
151 | migration.operations = [delete_op]
152 |
153 | mock_loader_instance = MagicMock()
154 | mock_loader_instance.get_migration_by_prefix.return_value = migration
155 | mock_loader.return_value = mock_loader_instance
156 |
157 | is_safe, warnings = check_migration_safety('testapp', '0001')
158 | assert not is_safe
159 | assert len(warnings) == 1
160 | assert 'deletes model' in warnings[0]
161 |
162 | if __name__ == '__main__':
163 | pytest.main([__file__])
```
--------------------------------------------------------------------------------
/migationsenv/bin/Activate.ps1:
--------------------------------------------------------------------------------
```
1 | <#
2 | .Synopsis
3 | Activate a Python virtual environment for the current PowerShell session.
4 |
5 | .Description
6 | Pushes the python executable for a virtual environment to the front of the
7 | $Env:PATH environment variable and sets the prompt to signify that you are
8 | in a Python virtual environment. Makes use of the command line switches as
9 | well as the `pyvenv.cfg` file values present in the virtual environment.
10 |
11 | .Parameter VenvDir
12 | Path to the directory that contains the virtual environment to activate. The
13 | default value for this is the parent of the directory that the Activate.ps1
14 | script is located within.
15 |
16 | .Parameter Prompt
17 | The prompt prefix to display when this virtual environment is activated. By
18 | default, this prompt is the name of the virtual environment folder (VenvDir)
19 | surrounded by parentheses and followed by a single space (ie. '(.venv) ').
20 |
21 | .Example
22 | Activate.ps1
23 | Activates the Python virtual environment that contains the Activate.ps1 script.
24 |
25 | .Example
26 | Activate.ps1 -Verbose
27 | Activates the Python virtual environment that contains the Activate.ps1 script,
28 | and shows extra information about the activation as it executes.
29 |
30 | .Example
31 | Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
32 | Activates the Python virtual environment located in the specified location.
33 |
34 | .Example
35 | Activate.ps1 -Prompt "MyPython"
36 | Activates the Python virtual environment that contains the Activate.ps1 script,
37 | and prefixes the current prompt with the specified string (surrounded in
38 | parentheses) while the virtual environment is active.
39 |
40 | .Notes
41 | On Windows, it may be required to enable this Activate.ps1 script by setting the
42 | execution policy for the user. You can do this by issuing the following PowerShell
43 | command:
44 |
45 | PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
46 |
47 | For more information on Execution Policies:
48 | https://go.microsoft.com/fwlink/?LinkID=135170
49 |
50 | #>
51 | Param(
52 | [Parameter(Mandatory = $false)]
53 | [String]
54 | $VenvDir,
55 | [Parameter(Mandatory = $false)]
56 | [String]
57 | $Prompt
58 | )
59 |
60 | <# Function declarations --------------------------------------------------- #>
61 |
62 | <#
63 | .Synopsis
64 | Remove all shell session elements added by the Activate script, including the
65 | addition of the virtual environment's Python executable from the beginning of
66 | the PATH variable.
67 |
68 | .Parameter NonDestructive
69 | If present, do not remove this function from the global namespace for the
70 | session.
71 |
72 | #>
73 | function global:deactivate ([switch]$NonDestructive) {
74 | # Revert to original values
75 |
76 | # The prior prompt:
77 | if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
78 | Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
79 | Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
80 | }
81 |
82 | # The prior PYTHONHOME:
83 | if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
84 | Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
85 | Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
86 | }
87 |
88 | # The prior PATH:
89 | if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
90 | Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
91 | Remove-Item -Path Env:_OLD_VIRTUAL_PATH
92 | }
93 |
94 | # Just remove the VIRTUAL_ENV altogether:
95 | if (Test-Path -Path Env:VIRTUAL_ENV) {
96 | Remove-Item -Path env:VIRTUAL_ENV
97 | }
98 |
99 | # Just remove VIRTUAL_ENV_PROMPT altogether.
100 | if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
101 | Remove-Item -Path env:VIRTUAL_ENV_PROMPT
102 | }
103 |
104 | # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
105 | if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
106 | Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
107 | }
108 |
109 | # Leave deactivate function in the global namespace if requested:
110 | if (-not $NonDestructive) {
111 | Remove-Item -Path function:deactivate
112 | }
113 | }
114 |
115 | <#
116 | .Description
117 | Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
118 | given folder, and returns them in a map.
119 |
120 | For each line in the pyvenv.cfg file, if that line can be parsed into exactly
121 | two strings separated by `=` (with any amount of whitespace surrounding the =)
122 | then it is considered a `key = value` line. The left hand string is the key,
123 | the right hand is the value.
124 |
125 | If the value starts with a `'` or a `"` then the first and last character is
126 | stripped from the value before being captured.
127 |
128 | .Parameter ConfigDir
129 | Path to the directory that contains the `pyvenv.cfg` file.
130 | #>
131 | function Get-PyVenvConfig(
132 | [String]
133 | $ConfigDir
134 | ) {
135 | Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
136 |
137 | # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
138 | $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
139 |
140 | # An empty map will be returned if no config file is found.
141 | $pyvenvConfig = @{ }
142 |
143 | if ($pyvenvConfigPath) {
144 |
145 | Write-Verbose "File exists, parse `key = value` lines"
146 | $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
147 |
148 | $pyvenvConfigContent | ForEach-Object {
149 | $keyval = $PSItem -split "\s*=\s*", 2
150 | if ($keyval[0] -and $keyval[1]) {
151 | $val = $keyval[1]
152 |
153 | # Remove extraneous quotations around a string value.
154 | if ("'""".Contains($val.Substring(0, 1))) {
155 | $val = $val.Substring(1, $val.Length - 2)
156 | }
157 |
158 | $pyvenvConfig[$keyval[0]] = $val
159 | Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
160 | }
161 | }
162 | }
163 | return $pyvenvConfig
164 | }
165 |
166 |
167 | <# Begin Activate script --------------------------------------------------- #>
168 |
169 | # Determine the containing directory of this script
170 | $VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
171 | $VenvExecDir = Get-Item -Path $VenvExecPath
172 |
173 | Write-Verbose "Activation script is located in path: '$VenvExecPath'"
174 | Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
175 | Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
176 |
177 | # Set values required in priority: CmdLine, ConfigFile, Default
178 | # First, get the location of the virtual environment, it might not be
179 | # VenvExecDir if specified on the command line.
180 | if ($VenvDir) {
181 | Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
182 | }
183 | else {
184 | Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
185 | $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
186 | Write-Verbose "VenvDir=$VenvDir"
187 | }
188 |
189 | # Next, read the `pyvenv.cfg` file to determine any required value such
190 | # as `prompt`.
191 | $pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
192 |
193 | # Next, set the prompt from the command line, or the config file, or
194 | # just use the name of the virtual environment folder.
195 | if ($Prompt) {
196 | Write-Verbose "Prompt specified as argument, using '$Prompt'"
197 | }
198 | else {
199 | Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
200 | if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
201 | Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
202 | $Prompt = $pyvenvCfg['prompt'];
203 | }
204 | else {
205 | Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
206 | Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
207 | $Prompt = Split-Path -Path $venvDir -Leaf
208 | }
209 | }
210 |
211 | Write-Verbose "Prompt = '$Prompt'"
212 | Write-Verbose "VenvDir='$VenvDir'"
213 |
214 | # Deactivate any currently active virtual environment, but leave the
215 | # deactivate function in place.
216 | deactivate -nondestructive
217 |
218 | # Now set the environment variable VIRTUAL_ENV, used by many tools to determine
219 | # that there is an activated venv.
220 | $env:VIRTUAL_ENV = $VenvDir
221 |
222 | if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
223 |
224 | Write-Verbose "Setting prompt to '$Prompt'"
225 |
226 | # Set the prompt to include the env name
227 | # Make sure _OLD_VIRTUAL_PROMPT is global
228 | function global:_OLD_VIRTUAL_PROMPT { "" }
229 | Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
230 | New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
231 |
232 | function global:prompt {
233 | Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
234 | _OLD_VIRTUAL_PROMPT
235 | }
236 | $env:VIRTUAL_ENV_PROMPT = $Prompt
237 | }
238 |
239 | # Clear PYTHONHOME
240 | if (Test-Path -Path Env:PYTHONHOME) {
241 | Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
242 | Remove-Item -Path Env:PYTHONHOME
243 | }
244 |
245 | # Add the venv to the PATH
246 | Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
247 | $Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
248 |
```