Aryan Jain commited on
Commit
b64aabb
·
1 Parent(s): fb37e37

setup basic fastapi backend

Browse files
.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ SQLALCHEMY_DATABASE_URI=postgresql+asyncpg://username:password@host:port/database_name
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use official Python 3.12 slim image
2
+ FROM python:3.12-slim
3
+
4
+ # Set environment variables
5
+ ENV SQLALCHEMY_DATABASE_URI=sqlite+aiosqlite:///:memory:
6
+ ENV PYTHONUNBUFFERED=1
7
+ ENV POETRY_VERSION=1.8.2
8
+
9
+ # Set work directory
10
+ WORKDIR /app
11
+
12
+ # Install system dependencies
13
+ RUN apt-get update && apt-get install -y \
14
+ curl \
15
+ build-essential \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ # Install Poetry
19
+ RUN curl -sSL https://install.python-poetry.org | python3 -
20
+ ENV PATH="/root/.local/bin:$PATH"
21
+
22
+ # Copy only dependency files first to leverage Docker cache
23
+ COPY pyproject.toml poetry.lock* /app/
24
+
25
+ # Install dependencies (without creating a virtualenv)
26
+ RUN poetry config virtualenvs.create false \
27
+ && poetry install --no-interaction --no-ansi
28
+
29
+ # Copy the entire project
30
+ COPY . /app/
31
+
32
+ # Expose the port Uvicorn will run on
33
+ EXPOSE 8000
34
+
35
+ # Run the FastAPI application
36
+ CMD ["uvicorn", "src.app:app", "--reload", "--host", "0.0.0.0", "--port", "8000"]
alembic.ini ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts.
5
+ # this is typically a path given in POSIX (e.g. forward slashes)
6
+ # format, relative to the token %(here)s which refers to the location of this
7
+ # ini file
8
+ script_location = %(here)s/alembic
9
+
10
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
11
+ # Uncomment the line below if you want the files to be prepended with date and time
12
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
13
+ # for all available tokens
14
+ # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
15
+
16
+ # sys.path path, will be prepended to sys.path if present.
17
+ # defaults to the current working directory. for multiple paths, the path separator
18
+ # is defined by "path_separator" below.
19
+ prepend_sys_path = .
20
+
21
+ # timezone to use when rendering the date within the migration file
22
+ # as well as the filename.
23
+ # If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
24
+ # Any required deps can installed by adding `alembic[tz]` to the pip requirements
25
+ # string value is passed to ZoneInfo()
26
+ # leave blank for localtime
27
+ # timezone =
28
+
29
+ # max length of characters to apply to the "slug" field
30
+ # truncate_slug_length = 40
31
+
32
+ # set to 'true' to run the environment during
33
+ # the 'revision' command, regardless of autogenerate
34
+ # revision_environment = false
35
+
36
+ # set to 'true' to allow .pyc and .pyo files without
37
+ # a source .py file to be detected as revisions in the
38
+ # versions/ directory
39
+ # sourceless = false
40
+
41
+ # version location specification; This defaults
42
+ # to <script_location>/versions. When using multiple version
43
+ # directories, initial revisions must be specified with --version-path.
44
+ # The path separator used here should be the separator specified by "path_separator"
45
+ # below.
46
+ # version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
47
+
48
+ # path_separator; This indicates what character is used to split lists of file
49
+ # paths, including version_locations and prepend_sys_path within configparser
50
+ # files such as alembic.ini.
51
+ # The default rendered in new alembic.ini files is "os", which uses os.pathsep
52
+ # to provide os-dependent path splitting.
53
+ #
54
+ # Note that in order to support legacy alembic.ini files, this default does NOT
55
+ # take place if path_separator is not present in alembic.ini. If this
56
+ # option is omitted entirely, fallback logic is as follows:
57
+ #
58
+ # 1. Parsing of the version_locations option falls back to using the legacy
59
+ # "version_path_separator" key, which if absent then falls back to the legacy
60
+ # behavior of splitting on spaces and/or commas.
61
+ # 2. Parsing of the prepend_sys_path option falls back to the legacy
62
+ # behavior of splitting on spaces, commas, or colons.
63
+ #
64
+ # Valid values for path_separator are:
65
+ #
66
+ # path_separator = :
67
+ # path_separator = ;
68
+ # path_separator = space
69
+ # path_separator = newline
70
+ #
71
+ # Use os.pathsep. Default configuration used for new projects.
72
+ path_separator = os
73
+
74
+
75
+ # set to 'true' to search source files recursively
76
+ # in each "version_locations" directory
77
+ # new in Alembic version 1.10
78
+ # recursive_version_locations = false
79
+
80
+ # the output encoding used when revision files
81
+ # are written from script.py.mako
82
+ # output_encoding = utf-8
83
+
84
+ # database URL. This is consumed by the user-maintained env.py script only.
85
+ # other means of configuring database URLs may be customized within the env.py
86
+ # file.
87
+ sqlalchemy.url = driver://user:pass@localhost/dbname
88
+
89
+
90
+ [post_write_hooks]
91
+ # post_write_hooks defines scripts or Python functions that are run
92
+ # on newly generated revision scripts. See the documentation for further
93
+ # detail and examples
94
+
95
+ # format using "black" - use the console_scripts runner, against the "black" entrypoint
96
+ # hooks = black
97
+ # black.type = console_scripts
98
+ # black.entrypoint = black
99
+ # black.options = -l 79 REVISION_SCRIPT_FILENAME
100
+
101
+ # lint with attempts to fix using "ruff" - use the exec runner, execute a binary
102
+ # hooks = ruff
103
+ # ruff.type = exec
104
+ # ruff.executable = %(here)s/.venv/bin/ruff
105
+ # ruff.options = check --fix REVISION_SCRIPT_FILENAME
106
+
107
+ # Logging configuration. This is also consumed by the user-maintained
108
+ # env.py script only.
109
+ [loggers]
110
+ keys = root,sqlalchemy,alembic
111
+
112
+ [handlers]
113
+ keys = console
114
+
115
+ [formatters]
116
+ keys = generic
117
+
118
+ [logger_root]
119
+ level = WARNING
120
+ handlers = console
121
+ qualname =
122
+
123
+ [logger_sqlalchemy]
124
+ level = WARNING
125
+ handlers =
126
+ qualname = sqlalchemy.engine
127
+
128
+ [logger_alembic]
129
+ level = INFO
130
+ handlers =
131
+ qualname = alembic
132
+
133
+ [handler_console]
134
+ class = StreamHandler
135
+ args = (sys.stderr,)
136
+ level = NOTSET
137
+ formatter = generic
138
+
139
+ [formatter_generic]
140
+ format = %(levelname)-5.5s [%(name)s] %(message)s
141
+ datefmt = %H:%M:%S
alembic/README ADDED
@@ -0,0 +1 @@
 
 
1
+ Generic single-database configuration with an async dbapi.
alembic/env.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from logging.config import fileConfig
3
+
4
+ from sqlalchemy import pool
5
+ from sqlalchemy.engine import Connection
6
+ from sqlalchemy.ext.asyncio import async_engine_from_config
7
+
8
+ from alembic import context
9
+
10
+ from src.models import *
11
+
12
+ # this is the Alembic Config object, which provides
13
+ # access to the values within the .ini file in use.
14
+ config = context.config
15
+
16
+ # Interpret the config file for Python logging.
17
+ # This line sets up loggers basically.
18
+ if config.config_file_name is not None:
19
+ fileConfig(config.config_file_name)
20
+
21
+ # add your model's MetaData object here
22
+ # for 'autogenerate' support
23
+ # from myapp import mymodel
24
+ # target_metadata = mymodel.Base.metadata
25
+ target_metadata = Base.metadata
26
+
27
+ # other values from the config, defined by the needs of env.py,
28
+ # can be acquired:
29
+ # my_important_option = config.get_main_option("my_important_option")
30
+ # ... etc.
31
+
32
+
33
+ def run_migrations_offline() -> None:
34
+ """Run migrations in 'offline' mode.
35
+
36
+ This configures the context with just a URL
37
+ and not an Engine, though an Engine is acceptable
38
+ here as well. By skipping the Engine creation
39
+ we don't even need a DBAPI to be available.
40
+
41
+ Calls to context.execute() here emit the given string to the
42
+ script output.
43
+
44
+ """
45
+ url = config.get_main_option("sqlalchemy.url")
46
+ context.configure(
47
+ url=url,
48
+ target_metadata=target_metadata,
49
+ literal_binds=True,
50
+ dialect_opts={"paramstyle": "named"},
51
+ )
52
+
53
+ with context.begin_transaction():
54
+ context.run_migrations()
55
+
56
+
57
+ def do_run_migrations(connection: Connection) -> None:
58
+ context.configure(connection=connection, target_metadata=target_metadata)
59
+
60
+ with context.begin_transaction():
61
+ context.run_migrations()
62
+
63
+
64
+ async def run_async_migrations() -> None:
65
+ """In this scenario we need to create an Engine
66
+ and associate a connection with the context.
67
+
68
+ """
69
+
70
+ connectable = async_engine_from_config(
71
+ config.get_section(config.config_ini_section, {}),
72
+ prefix="sqlalchemy.",
73
+ poolclass=pool.NullPool,
74
+ )
75
+
76
+ async with connectable.connect() as connection:
77
+ await connection.run_sync(do_run_migrations)
78
+
79
+ await connectable.dispose()
80
+
81
+
82
+ def run_migrations_online() -> None:
83
+ """Run migrations in 'online' mode."""
84
+
85
+ connectable = config.attributes.get("connection", None)
86
+
87
+ if connectable is None:
88
+ asyncio.run(run_async_migrations())
89
+ else:
90
+ do_run_migrations(connectable)
91
+
92
+
93
+ if context.is_offline_mode():
94
+ run_migrations_offline()
95
+ else:
96
+ run_migrations_online()
alembic/script.py.mako ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ """Upgrade schema."""
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ """Downgrade schema."""
28
+ ${downgrades if downgrades else "pass"}
poetry.lock ADDED
The diff for this file is too large to render. See raw diff
 
pyproject.toml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.poetry]
2
+ name = "ipns-sow"
3
+ version = "0.1.0"
4
+ description = ""
5
+ authors = ["Aryan Jain <77013565+aryanjain1908@users.noreply.github.com>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = "^3.12"
10
+ uvicorn = {extras = ["standard"], version = "^0.34.3"}
11
+ fastapi = "^0.115.12"
12
+ python-dotenv = "^1.1.0"
13
+ alembic = "^1.16.1"
14
+ asyncpg = "^0.30.0"
15
+ loguru = "^0.7.3"
16
+ pydantic = "^2.11.5"
17
+ aiosqlite = "^0.21.0"
18
+
19
+
20
+ [build-system]
21
+ requires = ["poetry-core"]
22
+ build-backend = "poetry.core.masonry.api"
src/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+ import os
3
+
4
+ from .app import app
5
+ from .config import logger
6
+
7
+ load_dotenv()
8
+
9
+ if not os.getenv("SQLALCHEMY_DATABASE_URI"):
10
+ logger.error("SQLALCHEMY_DATABASE_URI is not set")
11
+ raise ValueError("SQLALCHEMY_DATABASE_URI is not set")
12
+
13
+ __all__ = ["app"]
14
+ __version__ = "0.1.0"
15
+ __author__ = "Aryan Jain"
src/app.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ from contextlib import asynccontextmanager
4
+ from threading import Thread
5
+
6
+ from alembic import command
7
+ from alembic.config import Config
8
+ from fastapi import FastAPI
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import RedirectResponse
11
+
12
+ from src.config import DatabaseConfig, logger
13
+ from src.controllers import api_router
14
+
15
+
16
+
17
+ def run_upgrade(connection, alembic_config: Config):
18
+ alembic_config.attributes["connection"] = connection
19
+ command.upgrade(alembic_config, "head")
20
+
21
+
22
+ async def run_migrations():
23
+ logger.info("Running migrations if any...")
24
+ alembic_config = Config("alembic.ini")
25
+ alembic_config.set_main_option(
26
+ "sqlalchemy.url", os.getenv("SQLALCHEMY_DATABASE_URI")
27
+ )
28
+ async with DatabaseConfig.get_engine().begin() as session:
29
+ await session.run_sync(run_upgrade, alembic_config)
30
+
31
+
32
+ @asynccontextmanager
33
+ async def lifespan(app: FastAPI):
34
+ try:
35
+ logger.info("Starting up the application...")
36
+ await run_migrations()
37
+ logger.info("Application started successfully...")
38
+ yield
39
+ except Exception as e:
40
+ logger.error(f"Error during startup: {str(e)}")
41
+ raise
42
+ finally:
43
+ logger.info("Shutting down the application...")
44
+ logger.info("Application shutdown complete.")
45
+
46
+
47
+ app = FastAPI(lifespan=lifespan)
48
+
49
+
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ # allow_origins=os.getenv(
53
+ # "CORS_ALLOW_ORIGINS", "http://localhost, http://127.0.0.1"
54
+ # ).split(", "),
55
+ allow_origins=["*"],
56
+ allow_credentials=False,
57
+ allow_methods=["*"],
58
+ allow_headers=["*"],
59
+ )
60
+
61
+
62
+ @app.get("/")
63
+ async def check_health():
64
+ return RedirectResponse(url="/docs")
65
+
66
+
67
+ app.include_router(api_router, prefix="/api/v1")
src/config/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from ._database_config import DatabaseConfig
2
+ from ._logger import logger
3
+
4
+ __all__ = [
5
+ "DatabaseConfig",
6
+ "logger",
7
+ ]
8
+ __version__ = "0.1.0"
9
+ __author__ = "Aryan Jain"
src/config/_database_config.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ from contextlib import asynccontextmanager
4
+
5
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
6
+
7
+
8
+ class DatabaseConfig:
9
+ _thread_local = threading.local()
10
+
11
+ @classmethod
12
+ def get_engine(cls):
13
+ if not hasattr(cls._thread_local, "engine"):
14
+ cls._thread_local.engine = create_async_engine(
15
+ url=os.getenv("SQLALCHEMY_DATABASE_URI"),
16
+ pool_pre_ping=True,
17
+ pool_recycle=3600,
18
+ )
19
+ return cls._thread_local.engine
20
+
21
+ @classmethod
22
+ def _get_session_factory(cls):
23
+ if not hasattr(cls._thread_local, "session_factory"):
24
+ cls._thread_local.session_factory = async_sessionmaker(
25
+ bind=cls.get_engine(),
26
+ autoflush=False,
27
+ autocommit=False,
28
+ expire_on_commit=False,
29
+ )
30
+ return cls._thread_local.session_factory
31
+
32
+ @classmethod
33
+ @asynccontextmanager
34
+ async def async_session(cls):
35
+ session_factory = cls._get_session_factory()
36
+ async with session_factory() as session:
37
+ yield session
src/config/_logger.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from loguru import logger
4
+
5
+ LOG_FILE = os.getenv("LOG_FILE")
6
+ if LOG_FILE:
7
+ LOG_RETENTION = os.getenv("LOG_RETENTION", "90 days")
8
+ logger.add(LOG_FILE, retention=LOG_RETENTION)
src/controllers/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+
3
+ api_router = APIRouter()
4
+
5
+ __all__ = ["api_router"]
6
+ __version__ = "0.1.0"
7
+ __author__ = "Aryan Jain"
src/models/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from ._base import Base
2
+
3
+ __all__ = ["Base"]
4
+ __version__ = "0.1.0"
5
+ __author__ = "Aryan Jain"
src/models/_base.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from sqlalchemy.orm import declarative_base
2
+
3
+ Base = declarative_base()
src/repositories/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from ._base_repository import BaseRepository
2
+
3
+ __all__ = ["BaseRepository"]
4
+ __version__ = "0.1.0"
5
+ __author__ = "Aryan Jain"
src/repositories/_base_repository.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ from typing import Any, Dict, Optional, Type, TypeVar
3
+
4
+ from sqlalchemy import asc, desc
5
+ from sqlalchemy.exc import NoResultFound, SQLAlchemyError
6
+ from sqlalchemy.future import select
7
+
8
+ from src.config import DatabaseConfig
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class BaseRepository:
14
+ def __init__(self, model: Type[T]):
15
+ self._model = model
16
+
17
+ @asynccontextmanager
18
+ async def get_session(self):
19
+ async with DatabaseConfig.async_session() as session:
20
+ try:
21
+ yield session
22
+ except SQLAlchemyError as e:
23
+ await session.rollback()
24
+ raise e
25
+
26
+ async def create(self, **kwargs: Dict[str, Any]):
27
+ async with self.get_session() as session:
28
+ instance = self._model(**kwargs)
29
+ session.add(instance)
30
+ await session.commit()
31
+ await session.refresh(instance)
32
+ return instance
33
+
34
+ async def get(self, id: Any):
35
+ async with self.get_session() as session:
36
+ result = await session.get(self._model, id)
37
+ if result is None:
38
+ raise NoResultFound(f"{self._model.__name__} not found with id {id}")
39
+ return result
40
+
41
+ async def patch(self, id: Any, **kwargs: Dict[str, Any]):
42
+ async with self.get_session() as session:
43
+ instance = await session.get(self._model, id)
44
+ if not instance:
45
+ raise NoResultFound(f"{self._model.__name__} not found with id {id}")
46
+ for key, value in kwargs.items():
47
+ setattr(instance, key, value)
48
+ await session.commit()
49
+ await session.refresh(instance)
50
+ return instance
51
+
52
+ async def delete(self, id: Any):
53
+ async with self.get_session() as session:
54
+ instance = await session.get(self._model, id)
55
+ if not instance:
56
+ raise NoResultFound(f"{self._model.__name__} not found with id {id}")
57
+ await session.delete(instance)
58
+ await session.commit()
59
+ return True
60
+
61
+ async def get_all(
62
+ self,
63
+ page: int = 1,
64
+ page_size: int = 10,
65
+ filter_by: Optional[Dict[str, Any]] = {},
66
+ order_by: Optional[Dict[str, str]] = {},
67
+ ):
68
+ async with self.get_session() as session:
69
+ offset = (page - 1) * page_size
70
+ query = (
71
+ select(self._model)
72
+ .filter_by(**filter_by)
73
+ .offset(offset)
74
+ .limit(page_size)
75
+ )
76
+ for field, direction in order_by.items():
77
+ ordering = asc if direction == "asc" else desc
78
+ query = query.order_by(ordering(getattr(self._model, field)))
79
+ result = await session.execute(query)
80
+ return result.scalars().all()
81
+
82
+ async def execute(self, query):
83
+ async with self.get_session() as session:
84
+ result = await session.execute(query)
85
+ return result
src/services/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __version__ = "0.1.0"
2
+ __author__ = "Aryan Jain"
src/utils/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __version__ = "0.1.0"
2
+ __author__ = "Aryan Jain"
test.py ADDED
File without changes