adjango 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
adjango-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Artasov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ include LICENSE
2
+ include README.md
adjango-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.1
2
+ Name: adjango
3
+ Version: 0.0.1
4
+ Summary: A library with many features for interacting with Django
5
+ Home-page: https://github.com/Artasov/adjango
6
+ Author: xlartas
7
+ Author-email: ivanhvalevskey@gmail.com
8
+ Project-URL: Source, https://github.com/Artasov/adjango
9
+ Project-URL: Tracker, https://github.com/Artasov/adjango/issues
10
+ Keywords: adjango django utils funcs features async
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 4.0
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Intended Audience :: Developers
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: Django<5.3,>=4.0
26
+ Requires-Dist: pyperclip>=1.8.0
27
+ Requires-Dist: aiohttp>=3.8.0
28
+
29
+ # ADjango
30
+
31
+ **ADjango**
32
+ > Sometimes I use this in different projects, so I decided to put it on pypi
33
+
34
+ ## Installation
35
+ ```bash
36
+ pip install adjango
37
+ ```
38
+
39
+ ## Settings
40
+
41
+ * ### Add the application to the project.
42
+ ```python
43
+ INSTALLED_APPS = [
44
+ #...
45
+ 'adjango',
46
+ ]
47
+ ```
48
+ * ### In `settings.py` set the params
49
+ ```python
50
+ ADJANGO_BACKENDS_APPS = BASE_DIR / 'apps'
51
+ ADJANGO_FRONTEND_APPS = BASE_DIR.parent / 'frontend' / 'src' / 'apps'
52
+ ADJANGO_APPS_PREPATH = 'apps.'
53
+ ADJANGO_EXCEPTION_REPORT_EMAIL = ('ivanhvalevskey@gmail.com',)
54
+ ADJANGO_EXCEPTION_REPORT_TEMPLATE = 'core/error_report.html'
55
+ ADJANGO_LOGGER_NAME = 'global'
56
+ ADJANGO_EMAIL_LOGGER_NAME = 'email'
57
+ ```
58
+ ```python
59
+ MIDDLEWARE = [
60
+ ...
61
+ # add request.ip in views
62
+ 'adjango.middleware.IPAddressMiddleware',
63
+ ...
64
+ ]
65
+ ```
@@ -0,0 +1,37 @@
1
+ # ADjango
2
+
3
+ **ADjango**
4
+ > Sometimes I use this in different projects, so I decided to put it on pypi
5
+
6
+ ## Installation
7
+ ```bash
8
+ pip install adjango
9
+ ```
10
+
11
+ ## Settings
12
+
13
+ * ### Add the application to the project.
14
+ ```python
15
+ INSTALLED_APPS = [
16
+ #...
17
+ 'adjango',
18
+ ]
19
+ ```
20
+ * ### In `settings.py` set the params
21
+ ```python
22
+ ADJANGO_BACKENDS_APPS = BASE_DIR / 'apps'
23
+ ADJANGO_FRONTEND_APPS = BASE_DIR.parent / 'frontend' / 'src' / 'apps'
24
+ ADJANGO_APPS_PREPATH = 'apps.'
25
+ ADJANGO_EXCEPTION_REPORT_EMAIL = ('ivanhvalevskey@gmail.com',)
26
+ ADJANGO_EXCEPTION_REPORT_TEMPLATE = 'core/error_report.html'
27
+ ADJANGO_LOGGER_NAME = 'global'
28
+ ADJANGO_EMAIL_LOGGER_NAME = 'email'
29
+ ```
30
+ ```python
31
+ MIDDLEWARE = [
32
+ ...
33
+ # add request.ip in views
34
+ 'adjango.middleware.IPAddressMiddleware',
35
+ ...
36
+ ]
37
+ ```
File without changes
@@ -0,0 +1,8 @@
1
+ from django.apps import AppConfig
2
+ from django.utils.translation import gettext_lazy as _
3
+
4
+
5
+ class ADjangoConfig(AppConfig):
6
+ default_auto_field = 'django.db.models.BigAutoField'
7
+ name = 'adjango'
8
+ verbose_name = _('ADjango')
File without changes
File without changes
@@ -0,0 +1,137 @@
1
+ import os
2
+
3
+ from django.conf import settings
4
+ from django.core.management.base import BaseCommand
5
+
6
+
7
+ class Command(BaseCommand):
8
+ """
9
+ Adds a file path comment at the top of each .py and frontend file in the specified app(s).
10
+
11
+ @usage:
12
+ python manage.py add_paths [app_name] [--exclude name1 name2 ...]
13
+
14
+ @arg app_name: (optional) Name of the app to process. If not specified, all apps will be processed.
15
+ @arg --exclude: (optional) List of folder or file names to exclude from processing.
16
+ Defaults to excluding 'migrations' folders and '__init__.py' files.
17
+
18
+ @behavior:
19
+ - Process all backend and frontend apps (with default exclusions):
20
+ python manage.py add_paths
21
+
22
+ - Process a specific app (e.g., 'social_oauth'):
23
+ python manage.py add_paths social_oauth
24
+
25
+ - Process all apps, excluding additional folders or files:
26
+ python manage.py add_paths --exclude migrations __init__.py tests
27
+
28
+ - Process a specific app with specified exclusions:
29
+ python manage.py add_paths social_oauth --exclude migrations __init__.py tests
30
+ """
31
+
32
+ help = 'Adds a file path comment at the top of .py and frontend files in the specified app(s).'
33
+
34
+ def add_arguments(self, parser):
35
+ parser.add_argument(
36
+ 'app_name',
37
+ nargs='?',
38
+ default=None,
39
+ help='(Optional) Name of the app to process. If not specified, all apps will be processed.'
40
+ )
41
+ parser.add_argument(
42
+ '--exclude',
43
+ nargs='*',
44
+ default=['migrations', '__init__.py'],
45
+ help="List of folder or file names to exclude. Defaults to 'migrations' and '__init__.py'."
46
+ )
47
+
48
+ def handle(self, *args, **options):
49
+ app_name = options['app_name']
50
+ exclude_names = options['exclude']
51
+ backend_apps_dir = os.path.join(settings.BASE_DIR, 'apps')
52
+ frontend_apps_dir = os.path.join(settings.FRONTEND_DIR, 'src', 'apps')
53
+
54
+ # Process backend apps
55
+ self.process_apps(app_name, exclude_names, backend_apps_dir, '.py')
56
+
57
+ # Process frontend apps
58
+ self.process_apps(app_name, exclude_names, frontend_apps_dir, ('.ts', '.tsx', '.js', '.jsx'))
59
+
60
+ def process_apps(self, app_name, exclude_names, apps_dir, file_extensions):
61
+ if app_name:
62
+ app_paths = [os.path.join(apps_dir, app_name)]
63
+ if not os.path.exists(app_paths[0]):
64
+ self.stderr.write(f"App '{app_name}' does not exist in the specified directory.")
65
+ return
66
+ else:
67
+ # Process all apps in the directory
68
+ app_paths = [
69
+ os.path.join(apps_dir, name) for name in os.listdir(apps_dir)
70
+ if os.path.isdir(os.path.join(apps_dir, name)) and not name.startswith('__')
71
+ ]
72
+
73
+ for app_path in app_paths:
74
+ current_app = os.path.basename(app_path)
75
+ self.stdout.write(f"Processing app '{current_app}'...")
76
+ for root, dirs, files in os.walk(app_path):
77
+ # Exclude specified directories
78
+ dirs[:] = [d for d in dirs if d not in exclude_names]
79
+ for file_name in files:
80
+ if file_name.endswith(file_extensions) and file_name not in exclude_names:
81
+ file_path = os.path.join(root, file_name)
82
+ relative_path = os.path.relpath(file_path, apps_dir)
83
+ self.check_and_fix_file(file_path, relative_path, file_extensions)
84
+
85
+ def check_and_fix_file(self, file_path, relative_path, file_extensions):
86
+ """
87
+ Checks and updates the file by adding or correcting the file path comment at the top.
88
+
89
+ @param file_path: Full path to the file.
90
+ @param relative_path: Relative path of the file from the apps directory.
91
+ @param file_extensions: List of valid file extensions to determine comment type.
92
+ """
93
+ with open(file_path, 'r', encoding='utf-8') as f:
94
+ lines = f.readlines()
95
+
96
+ # Determine comment style based on file extension
97
+ if file_path.endswith('.py'):
98
+ expected_comment = f'# {relative_path.replace(os.sep, "/")}\n'
99
+ comment_prefix = '#'
100
+ else:
101
+ expected_comment = f'// {relative_path.replace(os.sep, "/")}\n'
102
+ comment_prefix = '//'
103
+
104
+ if lines:
105
+ first_line = lines[0].strip()
106
+ if first_line == expected_comment.strip():
107
+ # The file already has the correct comment
108
+ # self.stdout.write(f"{file_path}: OK")
109
+ return
110
+ elif first_line.startswith(('#', '//')):
111
+ # The file has a comment, but it's incorrect
112
+ if first_line != expected_comment.strip():
113
+ # Update the comment
114
+ lines[0] = expected_comment
115
+ self.write_file(file_path, lines)
116
+ self.stdout.write(f"{file_path}: Updated comment")
117
+ else:
118
+ # The first line is not a comment, insert the correct comment at the top
119
+ lines.insert(0, expected_comment)
120
+ self.write_file(file_path, lines)
121
+ self.stdout.write(f"{file_path}: Added missing comment")
122
+ else:
123
+ # File is empty, write the comment
124
+ lines = [expected_comment]
125
+ self.write_file(file_path, lines)
126
+ self.stdout.write(f"{file_path}: Added missing comment to empty file")
127
+
128
+ @staticmethod
129
+ def write_file(file_path, lines):
130
+ """
131
+ Writes the updated lines back to the file.
132
+
133
+ @param file_path: Full path to the file.
134
+ @param lines: List of lines to write to the file.
135
+ """
136
+ with open(file_path, 'w', encoding='utf-8') as f:
137
+ f.writelines(lines)
@@ -0,0 +1,146 @@
1
+ # core/management/commands/copy_proj.py
2
+ import os
3
+
4
+ import pyperclip
5
+ from django.conf import settings
6
+ from django.core.management.base import BaseCommand
7
+
8
+ TARGET_ALL = [
9
+ 'controllers',
10
+ 'serializers',
11
+ # 'exceptions',
12
+ 'tests',
13
+ 'models',
14
+ 'routes',
15
+ 'classes',
16
+ 'urls',
17
+ 'service',
18
+ 'services',
19
+ # 'decorators',
20
+ # 'permissions',
21
+ # 'tasks',
22
+ # 'middleware',
23
+ # 'forms',
24
+ # 'components',
25
+ # 'pages',
26
+ ]
27
+
28
+
29
+ class Command(BaseCommand):
30
+ """
31
+ @behavior:
32
+ Handles copying the project structure and
33
+ contents of specified files and directories.
34
+ @usage:
35
+ manage.py copy_proj [target_names] --apps [apps] [-b/--backend] [-f/--frontend]
36
+ @flags:
37
+ -b, --backend: Include backend files.
38
+ -f, --frontend: Include frontend files.
39
+ @args:
40
+ target_names: list of target directory/file names to collect. Defaults to all if not provided.
41
+ backend: flag to include backend files.
42
+ frontend: flag to include frontend files.
43
+ apps: list of app names to collect from. Defaults to all if not provided.
44
+ @raise CommandError:
45
+ If a specified app doesn't exist in backend/frontend.
46
+ """
47
+
48
+ help = 'Copy project structure and contents of specific files and directories.'
49
+
50
+ IGNORE_FILES = ['__init__.py']
51
+
52
+ def add_arguments(self, parser):
53
+ parser.add_argument(
54
+ 'target_names', nargs='*', type=str,
55
+ help='List of target names to collect. Defaults to all if not provided.'
56
+ )
57
+ parser.add_argument(
58
+ '--apps', nargs='+', type=str,
59
+ help='List of apps to collect from. Defaults to all if not provided.'
60
+ )
61
+ parser.add_argument(
62
+ '-b', '--backend', action='store_true',
63
+ help='Include backend files.'
64
+ )
65
+ parser.add_argument(
66
+ '-f', '--frontend', action='store_true',
67
+ help='Include frontend files.'
68
+ )
69
+
70
+ def handle(self, *args, **options):
71
+ target_names = options['target_names']
72
+ include_backend = options['backend']
73
+ include_frontend = options['frontend']
74
+
75
+ # Если флаги не указаны, по умолчанию копируем только backend
76
+ if not include_backend and not include_frontend:
77
+ include_backend = True
78
+
79
+ if not target_names or target_names[0] == 'all':
80
+ target_names = TARGET_ALL
81
+
82
+ apps_to_include = options['apps'] or self.get_all_apps()
83
+
84
+ result = []
85
+ collected_files = {name: [] for name in target_names}
86
+
87
+ if include_backend:
88
+ for app in apps_to_include:
89
+ app_path = os.path.join(settings.ADJANGO_APPS_PATH, app)
90
+ if not os.path.exists(app_path):
91
+ self.stdout.write(self.style.ERROR(f"App {app} does not exist in backend. Skipping."))
92
+ continue
93
+ for root, dirs, files in os.walk(app_path):
94
+ for name in target_names:
95
+ if name in dirs:
96
+ dir_path = os.path.join(root, name)
97
+ self.collect_directory_contents(dir_path, collected_files[name], settings.BASE_DIR)
98
+ if name + '.py' in files:
99
+ file_path = os.path.join(root, name + '.py')
100
+ self.collect_file_contents(file_path, collected_files[name], settings.BASE_DIR)
101
+
102
+ if include_frontend:
103
+ for app in apps_to_include:
104
+ app_path = os.path.join(settings.ADJANGO_FRONTEND_DIR, app)
105
+ if not os.path.exists(app_path):
106
+ self.stdout.write(self.style.ERROR(f"App {app} does not exist in frontend. Skipping."))
107
+ continue
108
+ for root, dirs, files in os.walk(app_path):
109
+ for name in target_names:
110
+ if name in dirs:
111
+ dir_path = os.path.join(root, name)
112
+ self.collect_directory_contents(dir_path, collected_files[name], settings.FRONTEND_DIR)
113
+ # Проверяем файлы с расширениями фронтенда
114
+ for ext in ['.ts', '.tsx', '.js', '.jsx']:
115
+ file_name = name + ext
116
+ if file_name in files:
117
+ file_path = os.path.join(root, file_name)
118
+ self.collect_file_contents(file_path, collected_files[name], settings.FRONTEND_DIR)
119
+
120
+ for name in target_names:
121
+ if collected_files[name]:
122
+ result.append(f'\n# {name.capitalize()}\n')
123
+ result.extend(collected_files[name])
124
+
125
+ final_text = '\n'.join(result)
126
+ pyperclip.copy(final_text)
127
+ self.stdout.write(self.style.SUCCESS('Project structure and contents copied to clipboard.'))
128
+ print(final_text)
129
+
130
+ def collect_directory_contents(self, dir_path, result, base_dir):
131
+ for sub_root, sub_dirs, sub_files in os.walk(dir_path):
132
+ for file in sub_files:
133
+ if (file.endswith('.py') or file.endswith('.ts') or file.endswith('.tsx') or file.endswith(
134
+ '.js') or file.endswith('.jsx')) and file not in self.IGNORE_FILES:
135
+ file_path = os.path.join(sub_root, file)
136
+ self.collect_file_contents(file_path, result, base_dir)
137
+
138
+ def collect_file_contents(self, file_path, result, base_dir):
139
+ relative_path = os.path.relpath(file_path, base_dir)
140
+ result.append(f'\n# {relative_path}\n')
141
+ with open(file_path, 'r', encoding='utf-8') as f:
142
+ result.append(f.read())
143
+
144
+ def get_all_apps(self):
145
+ apps_path = os.path.join(settings.ADJANGO_BACKENDS_APPS)
146
+ return [name for name in os.listdir(apps_path) if os.path.isdir(os.path.join(apps_path, name))]
@@ -0,0 +1,33 @@
1
+ import os
2
+
3
+ from django.apps import apps
4
+ from django.core.management import BaseCommand, call_command
5
+
6
+
7
+ class Command(BaseCommand):
8
+ """
9
+ Dumps data from all models into separate JSON files within a specified directory.
10
+
11
+ @usage: python manage.py <command> <directory>
12
+ """
13
+
14
+ help = 'Dumps data from all models into separate files within a specified directory'
15
+
16
+ def add_arguments(self, parser) -> None:
17
+ parser.add_argument('directory', type=str, help='Directory where data files will be saved')
18
+
19
+ def handle(self, *args: tuple, **options: dict) -> None:
20
+ directory: str = options['directory']
21
+ if not os.path.exists(directory):
22
+ os.makedirs(directory)
23
+
24
+ for app in apps.get_app_configs():
25
+ for model in app.get_models():
26
+ model_label = f"{app.label}_{model._meta.model_name}"
27
+ output_file_path = os.path.join(directory, f"{model_label}.json")
28
+ self.stdout.write(f"Dumping data for {model_label} into {output_file_path}")
29
+ try:
30
+ with open(output_file_path, 'w', encoding='utf-8') as output_file:
31
+ call_command('dumpdata', f'{app.label}.{model._meta.model_name}', stdout=output_file)
32
+ except Exception:
33
+ print(f'{model._meta.model_name} Not Loaded')
@@ -0,0 +1,53 @@
1
+ import os
2
+
3
+ from django.core.management import call_command
4
+ from django.core.management.base import BaseCommand
5
+
6
+
7
+ class Command(BaseCommand):
8
+ """
9
+ Loads data from all JSON files within a specified directory into the corresponding models.
10
+
11
+ @usage: python manage.py loaddata_from_dir <directory>
12
+ """
13
+
14
+ help = 'Loads data from all files within a specified directory into the corresponding models'
15
+
16
+ def add_arguments(self, parser) -> None:
17
+ parser.add_argument('directory', type=str, help='Directory from which data files will be loaded')
18
+
19
+ def handle(self, *args: tuple, **options: dict) -> None:
20
+ directory: str = options['directory']
21
+ if not os.path.exists(directory):
22
+ self.stdout.write(self.style.ERROR(f'Directory {directory} does not exist'))
23
+ return
24
+
25
+ total_files: int = 0
26
+ loaded_files: int = 0
27
+ failed_files: list = []
28
+ successful_files: list = []
29
+
30
+ for filename in os.listdir(directory):
31
+ if filename.endswith('.json'):
32
+ total_files += 1
33
+ self.stdout.write(f'Loading data from {filename}')
34
+ try:
35
+ call_command('loaddata', os.path.join(directory, filename))
36
+ loaded_files += 1
37
+ successful_files.append(filename)
38
+ except Exception as e:
39
+ self.stdout.write(self.style.ERROR(f'{filename} Not Loaded'))
40
+ self.stdout.write(self.style.ERROR(str(e)))
41
+ failed_files.append(filename)
42
+
43
+ self.stdout.write(self.style.SUCCESS(f'Successfully loaded {loaded_files}/{total_files} files.'))
44
+
45
+ if successful_files:
46
+ self.stdout.write(self.style.SUCCESS('Successfully loaded the following files:'))
47
+ for successful_file in successful_files:
48
+ self.stdout.write(self.style.SUCCESS(f'- {successful_file}'))
49
+
50
+ if failed_files:
51
+ self.stdout.write(self.style.ERROR('Failed to load the following files:'))
52
+ for failed_file in failed_files:
53
+ self.stdout.write(self.style.ERROR(f'- {failed_file}'))
@@ -0,0 +1,42 @@
1
+ # core/management/commands/remakemigrations.py
2
+ import glob
3
+ import os
4
+ from time import sleep
5
+
6
+ from django.conf import settings
7
+ from django.core.management import BaseCommand, call_command
8
+
9
+
10
+ class Command(BaseCommand):
11
+ help = ('Delete all migration files from apps in the "apps." namespace, '
12
+ 'delete SQLite database file if used, and run makemigrations and migrate')
13
+
14
+ def handle(self, *args, **kwargs):
15
+ apps_prepath = settings.ADJANGO_APPS_PREPATH
16
+ base_dir = settings.BASE_DIR
17
+
18
+ # Delete migration files
19
+ for app in settings.INSTALLED_APPS:
20
+ if apps_prepath is None or app.startswith(apps_prepath):
21
+ app_path = os.path.join(base_dir, app.replace('.', '/'))
22
+ migrations_path = os.path.join(app_path, 'migrations')
23
+ if os.path.exists(migrations_path):
24
+ files = glob.glob(os.path.join(migrations_path, '*.py'))
25
+ for file in files:
26
+ if os.path.basename(file) != '__init__.py':
27
+ os.remove(file)
28
+ self.stdout.write(f'Deleted {file}')
29
+
30
+ pyc_files = glob.glob(os.path.join(migrations_path, '*.pyc'))
31
+ for file in pyc_files:
32
+ os.remove(file)
33
+ self.stdout.write(f'Deleted {file}')
34
+
35
+ self.stdout.write('All migration files in apps.* deleted')
36
+ sleep(1)
37
+
38
+ # Run makemigrations
39
+ self.stdout.write('Running makemigrations...')
40
+ call_command('makemigrations')
41
+ self.stdout.write('Makemigrations completed')
42
+ sleep(1)
@@ -0,0 +1,18 @@
1
+ class IPAddressMiddleware:
2
+ """
3
+ Позволяет легко получать IP-адрес через `request.ip`.
4
+ """
5
+
6
+ def __init__(self, get_response):
7
+ self.get_response = get_response
8
+
9
+ def __call__(self, request):
10
+ if request.META.get('HTTP_X_FORWARDED_FOR'):
11
+ request.ip = request.META.get('HTTP_X_FORWARDED_FOR')
12
+ elif request.META.get("HTTP_X_REAL_IP"):
13
+ request.ip = request.META.get("HTTP_X_REAL_IP")
14
+ elif request.META.get("REMOTE_ADDR"):
15
+ request.ip = request.META.get("REMOTE_ADDR")
16
+ else:
17
+ request.ip = None
18
+ return self.get_response(request)
File without changes
@@ -0,0 +1,200 @@
1
+ import asyncio
2
+ import functools
3
+ import json
4
+ import logging
5
+ import time
6
+ from pprint import pprint
7
+ from time import time
8
+
9
+ import aiohttp
10
+ from asgiref.sync import sync_to_async
11
+ from django.conf import settings
12
+ from django.contrib.auth.models import Group
13
+ from django.core.files.base import ContentFile
14
+ from django.core.handlers.asgi import ASGIRequest
15
+ from django.core.handlers.wsgi import WSGIRequest
16
+ from django.db import transaction
17
+ from django.http import HttpResponseNotAllowed, HttpResponse, QueryDict, RawPostDataException
18
+ from django.shortcuts import redirect
19
+
20
+ from adjango.utils.common import traceback_str
21
+ from adjango.utils.mail import send_emails
22
+
23
+
24
+ async def download_file_to_temp(url: str) -> ContentFile:
25
+ """
26
+ Скачивает файл с URL и сохраняет его в объект ContentFile, не сохраняя на диск.
27
+
28
+ @param url: URL файла, который нужно скачать.
29
+ @return: Временный файл в памяти.
30
+ """
31
+ async with aiohttp.ClientSession() as session:
32
+ async with session.get(url) as response:
33
+ if response.status == 200:
34
+ file_content = await response.read()
35
+ file_name = url.split('/')[-1]
36
+ return ContentFile(file_content, name=file_name)
37
+ raise ValueError(f"Failed to download image from {url}, status code: {response.status}")
38
+
39
+
40
+ def add_user_to_group(user, group_name):
41
+ group, created = Group.objects.get_or_create(name=group_name)
42
+ if user not in group.user_set.all():
43
+ group.user_set.add(user)
44
+
45
+
46
+ def allowed_only(allowed_methods):
47
+ def decorator(view_func):
48
+ def wrapped_view(request, *args, **kwargs):
49
+ if request.method in allowed_methods:
50
+ return view_func(request, *args, **kwargs)
51
+ else:
52
+ return HttpResponseNotAllowed(allowed_methods)
53
+
54
+ return wrapped_view
55
+
56
+ return decorator
57
+
58
+
59
+ def aallowed_only(allowed_methods) -> callable:
60
+ def decorator(view_func) -> callable:
61
+ async def wrapped_view(request, *args, **kwargs) -> HttpResponse:
62
+ if request.method in allowed_methods:
63
+ if asyncio.iscoroutinefunction(view_func):
64
+ return await view_func(request, *args, **kwargs)
65
+ else:
66
+ return view_func(request, *args, **kwargs)
67
+ else:
68
+ return HttpResponseNotAllowed(allowed_methods)
69
+
70
+ return wrapped_view
71
+
72
+ return decorator
73
+
74
+
75
+ async def apprint(*args, **kwargs):
76
+ await sync_to_async(pprint)(*args, **kwargs)
77
+
78
+
79
+ def force_data(fn):
80
+ @functools.wraps(fn)
81
+ def _wrapped_view(request, *args, **kwargs):
82
+ data = {}
83
+
84
+ if isinstance(request.POST, QueryDict): # Объединяем данные из request.POST
85
+ data.update(request.POST.dict())
86
+ else:
87
+ data.update(request.POST)
88
+
89
+ if isinstance(request.GET, QueryDict): # Объединяем данные из request.GET
90
+ data.update(request.GET.dict())
91
+ else:
92
+ data.update(request.GET)
93
+
94
+ try: # Обрабатываем JSON-тело запроса, если оно есть
95
+ json_data = json.loads(request.body)
96
+ if isinstance(json_data, dict):
97
+ data.update(json_data)
98
+ except (ValueError, TypeError, RawPostDataException):
99
+ pass
100
+
101
+ setattr(request, 'get_data', lambda: data)
102
+ return fn(request, *args, **kwargs)
103
+
104
+ return _wrapped_view
105
+
106
+
107
+ def aforce_data(fn):
108
+ @functools.wraps(fn)
109
+ async def _wrapped_view(request, *args, **kwargs):
110
+ if isinstance(request.POST, QueryDict): # Объединяем данные из request.POST
111
+ request.data.update(request.POST.dict())
112
+ else:
113
+ request.data.update(request.POST)
114
+
115
+ if isinstance(request.GET, QueryDict): # Объединяем данные из request.GET
116
+ request.data.update(request.GET.dict())
117
+ else:
118
+ request.data.update(request.GET)
119
+
120
+ try: # Обрабатываем JSON-тело запроса, если оно есть
121
+ json_data = json.loads(request.body.decode('utf-8'))
122
+ if isinstance(json_data, dict):
123
+ request.data.update(json_data)
124
+ except (ValueError, TypeError, UnicodeDecodeError, RawPostDataException):
125
+ pass
126
+
127
+ return await fn(request, *args, **kwargs)
128
+
129
+ return _wrapped_view
130
+
131
+
132
+ def acontroller(name=None, logger=settings.ADJANGO_LOGGER_NAME, log_name=True, log_time=False) -> callable:
133
+ def decorator(fn) -> callable:
134
+ @functools.wraps(fn)
135
+ async def inner(request: ASGIRequest, *args, **kwargs):
136
+ log = logging.getLogger(logger)
137
+ fn_name = name or fn.__name__
138
+ if log_name: log.info(f'ACtrl: {request.method} | {fn_name}')
139
+ if log_time:
140
+ start_time = time()
141
+
142
+ if settings.DEBUG:
143
+ return await fn(request, *args, **kwargs)
144
+ else:
145
+ try:
146
+ if log_time:
147
+ end_time = time()
148
+ elapsed_time = end_time - start_time
149
+ log.info(f"Execution time of {fn_name}: {elapsed_time:.2f} seconds")
150
+ return await fn(request, *args, **kwargs)
151
+ except Exception as e:
152
+ log.critical(f"ERROR in {fn_name}: {traceback_str(e)}", exc_info=True)
153
+ send_emails(
154
+ subject='SERVER ERROR',
155
+ emails=settings.ADJANGO_EXCEPTION_REPORT_EMAILS,
156
+ template=settings.ADJANGO_EXCEPTION_REPORT_TEMPLATE,
157
+ context={'traceback': traceback_str(e), }
158
+ )
159
+ raise e
160
+
161
+ return inner
162
+
163
+ return decorator
164
+
165
+
166
+ def controller(name=None, logger=settings.ADJANGO_LOGGER_NAME, log_name=True, log_time=False,
167
+ auth_required=False, not_auth_redirect=settings.LOGIN_URL) -> callable:
168
+ def decorator(fn) -> callable:
169
+ @functools.wraps(fn)
170
+ def inner(request: WSGIRequest, *args, **kwargs):
171
+ log = logging.getLogger(logger)
172
+ fn_name = name or fn.__name__
173
+ if log_name: log.info(f'Ctrl: {request.method} | {fn_name}')
174
+ if log_time: start_time = time()
175
+ if auth_required:
176
+ if not request.user.is_authenticated: return redirect(not_auth_redirect)
177
+ if settings.DEBUG:
178
+ with transaction.atomic():
179
+ return fn(request, *args, **kwargs)
180
+ else:
181
+ try:
182
+ if log_time:
183
+ end_time = time()
184
+ elapsed_time = end_time - start_time
185
+ log.info(f"Execution time of {fn_name}: {elapsed_time:.2f} seconds")
186
+ with transaction.atomic():
187
+ return fn(request, *args, **kwargs)
188
+ except Exception as e:
189
+ log.critical(f"ERROR in {fn_name}: {traceback_str(e)}", exc_info=True)
190
+ send_emails(
191
+ subject='SERVER ERROR',
192
+ emails=settings.ADJANGO_EXCEPTION_REPORT_EMAILS,
193
+ template=settings.ADJANGO_EXCEPTION_REPORT_TEMPLATE,
194
+ context={'traceback': traceback_str(e), }
195
+ )
196
+ raise e
197
+
198
+ return inner
199
+
200
+ return decorator
@@ -0,0 +1,5 @@
1
+ import traceback
2
+
3
+
4
+ def traceback_str(error: BaseException) -> str:
5
+ return ''.join(traceback.format_exception(type(error), error, error.__traceback__))
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import wraps
4
+ from typing import Any, Type
5
+ from urllib.parse import urlparse
6
+
7
+ from asgiref.sync import sync_to_async
8
+ from django.conf import settings
9
+ from django.contrib.auth import REDIRECT_FIELD_NAME
10
+ from django.core.files.base import ContentFile
11
+ from django.db.models import QuerySet, Model, Manager
12
+ from django.db.transaction import Atomic
13
+ from django.shortcuts import resolve_url, redirect
14
+
15
+ from adjango.utils import download_file_to_temp
16
+
17
+
18
+ class AsyncAtomicContextManager(Atomic):
19
+ def __init__(self, using=None, savepoint=True, durable=False):
20
+ super().__init__(using, savepoint, durable)
21
+
22
+ async def __aenter__(self):
23
+ await sync_to_async(super().__enter__)()
24
+ return self
25
+
26
+ async def __aexit__(self, exc_type, exc_value, traceback):
27
+ await sync_to_async(super().__exit__)(exc_type, exc_value, traceback)
28
+
29
+
30
+ def aatomic(view_func):
31
+ @wraps(view_func)
32
+ async def _wrapped_view(request, *args, **kwargs):
33
+ async with AsyncAtomicContextManager():
34
+ return await view_func(request, *args, **kwargs)
35
+
36
+ return _wrapped_view
37
+
38
+
39
+ async def aget(
40
+ queryset: QuerySet,
41
+ exception: Type[Exception] | None = None,
42
+ *args: Any,
43
+ **kwargs: Any,
44
+
45
+ ) -> Model | None:
46
+ """
47
+ Асинхронно получает единственный объект из заданного QuerySet, соответствующий переданным параметрам.
48
+
49
+ @param queryset: QuerySet, из которого нужно получить объект.
50
+ @param exception: Класс исключения, которое будет выброшено, если объект не найден. Если None, возвращается None.
51
+
52
+ @return: Объект модели или None, если объект не найден и exception не задан.
53
+
54
+ @raises exception: Если объект не найден и передан класс исключения.
55
+
56
+ @behavior:
57
+ - Пытается асинхронно получить объект с помощью queryset.aget().
58
+ - Если объект не найден, обрабатывает исключение DoesNotExist.
59
+ - Если exception is not None, выбрасывает указанное исключение.
60
+ - Если exception is None, возвращает None.
61
+
62
+ @usage:
63
+ result = await aget(MyModel.objects, MyCustomException, id=1)
64
+ """
65
+ try:
66
+ return await queryset.aget(*args, **kwargs)
67
+ except queryset.model.DoesNotExist:
68
+ if exception is not None:
69
+ raise exception()
70
+ return None
71
+
72
+
73
+ async def arelated(model_object, related_field_name: str) -> object or None:
74
+ return await sync_to_async(getattr)(model_object, related_field_name, None)
75
+
76
+
77
+ async def aadd(queryset, data, *args, **kwargs):
78
+ return await sync_to_async(queryset.add)(data, *args, **kwargs)
79
+
80
+
81
+ async def aall(objects: Manager) -> list:
82
+ return await sync_to_async(list)(objects.all())
83
+
84
+
85
+ async def afilter(queryset, *args, **kwargs) -> list:
86
+ """
87
+ This function is used to filter objects...
88
+ :param queryset:
89
+ :param args:
90
+ :param kwargs:
91
+ :return: List of ...
92
+ """
93
+ return await sync_to_async(list)(queryset.filter(*args, **kwargs))
94
+
95
+
96
+ def auser_passes_test(test_func, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME):
97
+ """
98
+ Decorator for views that checks that the user passes the given test,
99
+ redirecting to the log-in page if necessary. The test should be a callable
100
+ that takes the user object and returns True if the user passes.
101
+ """
102
+ if not login_url:
103
+ login_url = settings.LOGIN_URL
104
+
105
+ def decorator(view_func):
106
+ @wraps(view_func)
107
+ async def _wrapped_view(request, *args, **kwargs):
108
+ if await test_func(request.user):
109
+ return await view_func(request, *args, **kwargs)
110
+ path = request.build_absolute_uri()
111
+ resolved_login_url = resolve_url(login_url)
112
+ # If the login_url is the same scheme and net location then just
113
+ # use the path as the "next" url.
114
+ login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
115
+ current_scheme, current_netloc = urlparse(path)[:2]
116
+ if ((not login_scheme or login_scheme == current_scheme) and
117
+ (not login_netloc or login_netloc == current_netloc)):
118
+ path = request.get_full_path()
119
+ return redirect(login_url)
120
+
121
+ return _wrapped_view
122
+
123
+ return decorator
124
+
125
+
126
+ def alogin_required(
127
+ function=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url=None
128
+ ):
129
+ """
130
+ Decorator for views that checks that the user is logged in, redirecting
131
+ to the log-in page if necessary.
132
+ """
133
+ actual_decorator = auser_passes_test(
134
+ sync_to_async(lambda u: u.is_authenticated),
135
+ login_url=login_url,
136
+ redirect_field_name=redirect_field_name,
137
+ )
138
+ if function:
139
+ return actual_decorator(function)
140
+ return actual_decorator
141
+
142
+
143
+ async def set_image_by_url(model_obj: Model, field_name: str, image_url: str) -> None:
144
+ """
145
+ Загружает изображение с заданного URL и устанавливает его в указанное поле модели без
146
+ предварительного сохранения файла на диск.
147
+
148
+ @param model_obj: Экземпляр модели, в который нужно установить изображение.
149
+ @param field_name: Название поля, в которое нужно сохранить изображение.
150
+ @param image_url: URL изображения, которое нужно загрузить.
151
+ @return: None
152
+ """
153
+ # Скачиваем изображение как объект ContentFile
154
+ image_file: ContentFile = await download_file_to_temp(image_url)
155
+
156
+ # Используем setattr, чтобы установить файл в поле модели
157
+ await sync_to_async(getattr(model_obj, field_name).save)(image_file.name, image_file)
158
+ await model_obj.asave()
@@ -0,0 +1,30 @@
1
+ import json
2
+ import logging
3
+
4
+ from django.conf import settings
5
+ from django.core.mail import send_mail
6
+ from django.template.loader import render_to_string
7
+
8
+ log = logging.getLogger(settings.ADJANGO_EMAIL_LOGGER_NAME)
9
+
10
+
11
+ def send_emails(subject: str, emails: tuple, template: str, context=None):
12
+ """
13
+ Отправляет email с использованием указанного шаблона.
14
+
15
+ @param subject: Тема письма.
16
+ @param emails: Список email-адресов получателей.
17
+ @param template: Путь к шаблону письма.
18
+ @param context: Контекст для рендеринга шаблона.
19
+ """
20
+ if send_mail(
21
+ subject=subject, message=str(json.dumps(context)),
22
+ from_email=settings.EMAIL_HOST_USER,
23
+ recipient_list=list(emails),
24
+ html_message=render_to_string(template, context=context if context is not None else {})
25
+ ):
26
+ log.info(f'Successfully sent template={template} emails {", ".join(emails)}')
27
+ else:
28
+ log.critical(
29
+ f'Failed to send template={template} emails {", ".join(emails)} context={str(json.dumps(context))}'
30
+ )
@@ -0,0 +1,105 @@
1
+ from django.core.cache import cache as djc
2
+
3
+ MIN_1 = 1 * 60
4
+ MIN_5 = 5 * 60
5
+ MIN_10 = 10 * 60
6
+ MIN_15 = 15 * 60
7
+ MIN_20 = 20 * 60
8
+ MIN_25 = 25 * 60
9
+ MIN_30 = 30 * 60
10
+ MIN_40 = 40 * 60
11
+ MIN_50 = 50 * 60
12
+ HOUR_1 = 1 * 60 * 60
13
+ HOUR_2 = 2 * 60 * 60
14
+ HOUR_3 = 3 * 60 * 60
15
+ HOUR_4 = 4 * 60 * 60
16
+ HOUR_5 = 5 * 60 * 60
17
+ HOUR_6 = 6 * 60 * 60
18
+ HOUR_7 = 7 * 60 * 60
19
+ HOUR_8 = 8 * 60 * 60
20
+ HOUR_9 = 9 * 60 * 60
21
+ HOUR_10 = 10 * 60 * 60
22
+ HOUR_11 = 11 * 60 * 60
23
+ HOUR_12 = 12 * 60 * 60
24
+ HOUR_13 = 13 * 60 * 60
25
+ HOUR_14 = 14 * 60 * 60
26
+ HOUR_15 = 15 * 60 * 60
27
+ HOUR_16 = 16 * 60 * 60
28
+ HOUR_17 = 17 * 60 * 60
29
+ HOUR_18 = 18 * 60 * 60
30
+ HOUR_19 = 19 * 60 * 60
31
+ HOUR_20 = 20 * 60 * 60
32
+ HOUR_21 = 21 * 60 * 60
33
+ HOUR_22 = 22 * 60 * 60
34
+ HOUR_23 = 23 * 60 * 60
35
+ DAY_1 = 1 * 60 * 60 * 24
36
+ DAYS_2 = 2 * 60 * 60 * 24
37
+ DAYS_3 = 3 * 60 * 60 * 24
38
+ DAYS_4 = 4 * 60 * 60 * 24
39
+ DAYS_5 = 5 * 60 * 60 * 24
40
+ DAYS_6 = 6 * 60 * 60 * 24
41
+ DAYS_7 = 7 * 60 * 60 * 24
42
+ DAYS_8 = 8 * 60 * 60 * 24
43
+ DAYS_9 = 9 * 60 * 60 * 24
44
+ DAYS_10 = 10 * 60 * 60 * 24
45
+ DAYS_11 = 11 * 60 * 60 * 24
46
+ DAYS_12 = 12 * 60 * 60 * 24
47
+ DAYS_13 = 13 * 60 * 60 * 24
48
+ DAYS_14 = 14 * 60 * 60 * 24
49
+ DAYS_15 = 15 * 60 * 60 * 24
50
+ DAYS_16 = 16 * 60 * 60 * 24
51
+ DAYS_17 = 17 * 60 * 60 * 24
52
+ DAYS_18 = 18 * 60 * 60 * 24
53
+ DAYS_19 = 19 * 60 * 60 * 24
54
+ DAYS_20 = 20 * 60 * 60 * 24
55
+
56
+
57
+ class CacheNotFound(Exception): pass
58
+
59
+
60
+ class Rediser:
61
+ """Рофлан класыч для удобного использования кэширования в Django."""
62
+
63
+ @staticmethod
64
+ def cache(name, obj=None, timeout=HOUR_12, *args, **kwargs):
65
+ """
66
+ Кэширует результат функции или возвращает кэшированный результат, если он доступен.
67
+
68
+ @param name: Базовое имя кэш-записи.
69
+ @param obj: Объект или функция для кэширования.
70
+ @param timeout: Время в секундах до истечения срока действия.
71
+ @return: Кэшированный результат или результат вызова функции.
72
+ """
73
+ if djc.get(name) is not None: return False, djc.get(name)
74
+ if obj is None:
75
+ raise CacheNotFound(f'No cache found for {name}')
76
+ else:
77
+ result = obj(*args, **kwargs) if callable(obj) else obj
78
+ djc.set(name, result, timeout=timeout)
79
+ return True, result
80
+
81
+ @staticmethod
82
+ async def acache(name, obj=None, timeout=HOUR_12, *args, **kwargs):
83
+ """
84
+ Асинхронно кэширует результат функции или возвращает кэшированный результат, если он доступен.
85
+
86
+ @param name: Базовое имя кэш-записи.
87
+ @param obj: Объект или функция для кэширования.
88
+ @param timeout: Время в секундах до истечения срока действия.
89
+ @return: Кэшированный результат или результат вызова функции.
90
+ """
91
+ if djc.get(name) is not None: return False, djc.get(name)
92
+ if obj is None:
93
+ raise CacheNotFound(f'No cache found for {name}')
94
+ else:
95
+ result = await obj(*args, **kwargs) if callable(obj) else obj
96
+ djc.set(name, result, timeout=timeout)
97
+ return True, result
98
+
99
+ @staticmethod
100
+ def delete(name):
101
+ djc.delete(name)
102
+
103
+ @staticmethod
104
+ def delete_all():
105
+ djc.clear()
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.1
2
+ Name: adjango
3
+ Version: 0.0.1
4
+ Summary: A library with many features for interacting with Django
5
+ Home-page: https://github.com/Artasov/adjango
6
+ Author: xlartas
7
+ Author-email: ivanhvalevskey@gmail.com
8
+ Project-URL: Source, https://github.com/Artasov/adjango
9
+ Project-URL: Tracker, https://github.com/Artasov/adjango/issues
10
+ Keywords: adjango django utils funcs features async
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 4.0
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: License :: OSI Approved :: MIT License
20
+ Classifier: Intended Audience :: Developers
21
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: Django<5.3,>=4.0
26
+ Requires-Dist: pyperclip>=1.8.0
27
+ Requires-Dist: aiohttp>=3.8.0
28
+
29
+ # ADjango
30
+
31
+ **ADjango**
32
+ > Sometimes I use this in different projects, so I decided to put it on pypi
33
+
34
+ ## Installation
35
+ ```bash
36
+ pip install adjango
37
+ ```
38
+
39
+ ## Settings
40
+
41
+ * ### Add the application to the project.
42
+ ```python
43
+ INSTALLED_APPS = [
44
+ #...
45
+ 'adjango',
46
+ ]
47
+ ```
48
+ * ### In `settings.py` set the params
49
+ ```python
50
+ ADJANGO_BACKENDS_APPS = BASE_DIR / 'apps'
51
+ ADJANGO_FRONTEND_APPS = BASE_DIR.parent / 'frontend' / 'src' / 'apps'
52
+ ADJANGO_APPS_PREPATH = 'apps.'
53
+ ADJANGO_EXCEPTION_REPORT_EMAIL = ('ivanhvalevskey@gmail.com',)
54
+ ADJANGO_EXCEPTION_REPORT_TEMPLATE = 'core/error_report.html'
55
+ ADJANGO_LOGGER_NAME = 'global'
56
+ ADJANGO_EMAIL_LOGGER_NAME = 'email'
57
+ ```
58
+ ```python
59
+ MIDDLEWARE = [
60
+ ...
61
+ # add request.ip in views
62
+ 'adjango.middleware.IPAddressMiddleware',
63
+ ...
64
+ ]
65
+ ```
@@ -0,0 +1,26 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ setup.cfg
5
+ setup.py
6
+ adjango/__init__.py
7
+ adjango/apps.py
8
+ adjango/middleware.py
9
+ adjango.egg-info/PKG-INFO
10
+ adjango.egg-info/SOURCES.txt
11
+ adjango.egg-info/dependency_links.txt
12
+ adjango.egg-info/requires.txt
13
+ adjango.egg-info/top_level.txt
14
+ adjango/management/__init__.py
15
+ adjango/management/commands/__init__.py
16
+ adjango/management/commands/add_paths.py
17
+ adjango/management/commands/copy_proj.py
18
+ adjango/management/commands/dumpdata_to_dir.py
19
+ adjango/management/commands/loaddata_from_dir.py
20
+ adjango/management/commands/remakemigrations.py
21
+ adjango/utils/__init__.py
22
+ adjango/utils/base.py
23
+ adjango/utils/common.py
24
+ adjango/utils/funcs.py
25
+ adjango/utils/mail.py
26
+ adjango/utils/rediser.py
@@ -0,0 +1,3 @@
1
+ Django<5.3,>=4.0
2
+ pyperclip>=1.8.0
3
+ aiohttp>=3.8.0
@@ -0,0 +1 @@
1
+ adjango
@@ -0,0 +1,7 @@
1
+ [metadata]
2
+ license_files = LICENSE
3
+
4
+ [egg_info]
5
+ tag_build =
6
+ tag_date = 0
7
+
adjango-0.0.1/setup.py ADDED
@@ -0,0 +1,41 @@
1
+ import setuptools
2
+
3
+ with open("README.md", "r", encoding="utf-8") as fh:
4
+ long_description = fh.read()
5
+
6
+ setuptools.setup(
7
+ name="adjango",
8
+ version="0.0.1",
9
+ author="xlartas",
10
+ author_email="ivanhvalevskey@gmail.com",
11
+ description="A library with many features for interacting with Django",
12
+ long_description=long_description,
13
+ long_description_content_type="text/markdown",
14
+ url="https://github.com/Artasov/adjango",
15
+ packages=setuptools.find_packages(),
16
+ include_package_data=True,
17
+ install_requires=[
18
+ "Django>=4.0,<5.3",
19
+ "pyperclip>=1.8.0",
20
+ "aiohttp>=3.8.0",
21
+ ],
22
+ classifiers=[
23
+ "Framework :: Django",
24
+ "Framework :: Django :: 4.0",
25
+ "Programming Language :: Python :: 3.8",
26
+ "Programming Language :: Python :: 3.9",
27
+ "Programming Language :: Python :: 3.10",
28
+ "Programming Language :: Python :: 3.11",
29
+ "Programming Language :: Python :: 3.12",
30
+ "Operating System :: OS Independent",
31
+ "License :: OSI Approved :: MIT License",
32
+ "Intended Audience :: Developers",
33
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
34
+ ],
35
+ python_requires='>=3.8',
36
+ keywords='adjango django utils funcs features async',
37
+ project_urls={
38
+ 'Source': 'https://github.com/Artasov/adjango',
39
+ 'Tracker': 'https://github.com/Artasov/adjango/issues',
40
+ },
41
+ )