├── pyproject.toml ├── README.md ├── args.py ├── .gitignore ├── thttp.py └── djbs.py /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | 4 | [tool.isort] 5 | line_length=120 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django Bootstrap 2 | 3 | _Very much a work in progress_. 4 | 5 | ## Usage 6 | 7 | ``` 8 | python3 djbs.py 9 | ``` 10 | 11 | You will be prompted to provide an app name, domain and base directory for the project. 12 | -------------------------------------------------------------------------------- /args.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | def parse_args(args): 5 | result = { 6 | a.split("=")[0]: int(a.split("=")[1]) 7 | if "=" in a and a.split("=")[1].isnumeric() 8 | else a.split("=")[1] 9 | if "=" in a 10 | else True 11 | for a in args 12 | if "--" in a 13 | } 14 | result["[]"] = [a for a in args if not a.startswith("--")] 15 | return result 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/96d68766538413194aabd55e3622734cd501e715/Python.gitignore 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 107 | __pypackages__/ 108 | 109 | # Celery stuff 110 | celerybeat-schedule 111 | celerybeat.pid 112 | 113 | # SageMath parsed files 114 | *.sage.py 115 | 116 | # Environments 117 | .env 118 | .venv 119 | env/ 120 | venv/ 121 | ENV/ 122 | env.bak/ 123 | venv.bak/ 124 | 125 | # Spyder project settings 126 | .spyderproject 127 | .spyproject 128 | 129 | # Rope project settings 130 | .ropeproject 131 | 132 | # mkdocs documentation 133 | /site 134 | 135 | # mypy 136 | .mypy_cache/ 137 | .dmypy.json 138 | dmypy.json 139 | 140 | # Pyre type checker 141 | .pyre/ 142 | 143 | # pytype static type analyzer 144 | .pytype/ 145 | 146 | # Cython debug symbols 147 | cython_debug/ 148 | 149 | # PyCharm 150 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 151 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 152 | # and can be added to the global gitignore or merged into this file. For a more nuclear 153 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 154 | #.idea/ 155 | 156 | 157 | -------------------------------------------------------------------------------- /thttp.py: -------------------------------------------------------------------------------- 1 | """ 2 | UNLICENSED 3 | This is free and unencumbered software released into the public domain. 4 | 5 | https://github.com/sesh/thttp 6 | """ 7 | 8 | import gzip 9 | import json as json_lib 10 | import ssl 11 | from base64 import b64encode 12 | from collections import namedtuple 13 | from http.cookiejar import CookieJar 14 | from urllib.error import HTTPError, URLError 15 | from urllib.parse import urlencode 16 | from urllib.request import HTTPCookieProcessor, HTTPRedirectHandler, HTTPSHandler, Request, build_opener 17 | 18 | Response = namedtuple("Response", "request content json status url headers cookiejar") 19 | 20 | 21 | class NoRedirect(HTTPRedirectHandler): 22 | def redirect_request(self, req, fp, code, msg, headers, newurl): 23 | return None 24 | 25 | 26 | def request( 27 | url, 28 | params={}, 29 | json=None, 30 | data=None, 31 | headers={}, 32 | method="GET", 33 | verify=True, 34 | redirect=True, 35 | cookiejar=None, 36 | basic_auth=None, 37 | timeout=None, 38 | ): 39 | """ 40 | Returns a (named)tuple with the following properties: 41 | - request 42 | - content 43 | - json (dict; or None) 44 | - headers (dict; all lowercase keys) 45 | - https://stackoverflow.com/questions/5258977/are-http-headers-case-sensitive 46 | - status 47 | - url (final url, after any redirects) 48 | - cookiejar 49 | """ 50 | method = method.upper() 51 | headers = {k.lower(): v for k, v in headers.items()} # lowecase headers 52 | 53 | if params: 54 | url += "?" + urlencode(params) # build URL from params 55 | if json and data: 56 | raise Exception("Cannot provide both json and data parameters") 57 | if method not in ["POST", "PATCH", "PUT"] and (json or data): 58 | raise Exception("Request method must POST, PATCH or PUT if json or data is provided") 59 | if not timeout: 60 | timeout = 60 61 | 62 | if json: # if we have json, stringify and put it in our data variable 63 | headers["content-type"] = "application/json" 64 | data = json_lib.dumps(json).encode("utf-8") 65 | elif data: 66 | data = urlencode(data).encode() 67 | 68 | if basic_auth and len(basic_auth) == 2 and "authorization" not in headers: 69 | username, password = basic_auth 70 | headers["authorization"] = f'Basic {b64encode(f"{username}:{password}".encode()).decode("ascii")}' 71 | 72 | if not cookiejar: 73 | cookiejar = CookieJar() 74 | 75 | ctx = ssl.create_default_context() 76 | if not verify: # ignore ssl errors 77 | ctx.check_hostname = False 78 | ctx.verify_mode = ssl.CERT_NONE 79 | 80 | handlers = [] 81 | handlers.append(HTTPSHandler(context=ctx)) 82 | handlers.append(HTTPCookieProcessor(cookiejar=cookiejar)) 83 | 84 | if not redirect: 85 | no_redirect = NoRedirect() 86 | handlers.append(no_redirect) 87 | 88 | opener = build_opener(*handlers) 89 | req = Request(url, data=data, headers=headers, method=method) 90 | 91 | try: 92 | with opener.open(req, timeout=timeout) as resp: 93 | status, content, resp_url = (resp.getcode(), resp.read(), resp.geturl()) 94 | headers = {k.lower(): v for k, v in list(resp.info().items())} 95 | 96 | if "gzip" in headers.get("content-encoding", ""): 97 | content = gzip.decompress(content) 98 | 99 | json = ( 100 | json_lib.loads(content) 101 | if "application/json" in headers.get("content-type", "").lower() and content 102 | else None 103 | ) 104 | except HTTPError as e: 105 | status, content, resp_url = (e.code, e.read(), e.geturl()) 106 | headers = {k.lower(): v for k, v in list(e.headers.items())} 107 | 108 | if "gzip" in headers.get("content-encoding", ""): 109 | content = gzip.decompress(content) 110 | 111 | json = ( 112 | json_lib.loads(content) 113 | if "application/json" in headers.get("content-type", "").lower() and content 114 | else None 115 | ) 116 | 117 | return Response(req, content, json, status, resp_url, headers, cookiejar) 118 | 119 | 120 | import unittest 121 | 122 | 123 | class RequestTestCase(unittest.TestCase): 124 | def test_cannot_provide_json_and_data(self): 125 | with self.assertRaises(Exception): 126 | request( 127 | "https://httpbingo.org/post", 128 | json={"name": "Brenton"}, 129 | data="This is some form data", 130 | ) 131 | 132 | def test_should_fail_if_json_or_data_and_not_p_method(self): 133 | with self.assertRaises(Exception): 134 | request("https://httpbingo.org/post", json={"name": "Brenton"}) 135 | 136 | with self.assertRaises(Exception): 137 | request("https://httpbingo.org/post", json={"name": "Brenton"}, method="HEAD") 138 | 139 | def test_should_set_content_type_for_json_request(self): 140 | response = request("https://httpbingo.org/post", json={"name": "Brenton"}, method="POST") 141 | self.assertEqual(response.request.headers["Content-type"], "application/json") 142 | 143 | def test_should_work(self): 144 | response = request("https://httpbingo.org/get") 145 | self.assertEqual(response.status, 200) 146 | 147 | def test_should_create_url_from_params(self): 148 | response = request( 149 | "https://httpbingo.org/get", 150 | params={"name": "brenton", "library": "tiny-request"}, 151 | ) 152 | self.assertEqual(response.url, "https://httpbingo.org/get?name=brenton&library=tiny-request") 153 | 154 | def test_should_return_headers(self): 155 | response = request("https://httpbingo.org/response-headers", params={"Test-Header": "value"}) 156 | self.assertEqual(response.headers["test-header"], "value") 157 | 158 | def test_should_populate_json(self): 159 | response = request("https://httpbingo.org/json") 160 | self.assertTrue("slideshow" in response.json) 161 | 162 | def test_should_return_response_for_404(self): 163 | response = request("https://httpbingo.org/404") 164 | self.assertEqual(response.status, 404) 165 | self.assertTrue("text/plain" in response.headers["content-type"]) 166 | 167 | def test_should_fail_with_bad_ssl(self): 168 | with self.assertRaises(URLError): 169 | response = request("https://expired.badssl.com/") 170 | 171 | def test_should_load_bad_ssl_with_verify_false(self): 172 | response = request("https://expired.badssl.com/", verify=False) 173 | self.assertEqual(response.status, 200) 174 | 175 | def test_should_form_encode_non_json_post_requests(self): 176 | response = request("https://httpbingo.org/post", data={"name": "test-user"}, method="POST") 177 | self.assertEqual(response.json["form"]["name"], ["test-user"]) 178 | 179 | def test_should_follow_redirect(self): 180 | response = request( 181 | "https://httpbingo.org/redirect-to", 182 | params={"url": "https://duckduckgo.com/"}, 183 | ) 184 | self.assertEqual(response.url, "https://duckduckgo.com/") 185 | self.assertEqual(response.status, 200) 186 | 187 | def test_should_not_follow_redirect_if_redirect_false(self): 188 | response = request( 189 | "https://httpbingo.org/redirect-to", 190 | params={"url": "https://duckduckgo.com/"}, 191 | redirect=False, 192 | ) 193 | self.assertEqual(response.status, 302) 194 | 195 | def test_cookies(self): 196 | response = request( 197 | "https://httpbingo.org/cookies/set", 198 | params={"cookie": "test"}, 199 | redirect=False, 200 | ) 201 | response = request("https://httpbingo.org/cookies", cookiejar=response.cookiejar) 202 | self.assertEqual(response.json["cookie"], "test") 203 | 204 | def test_basic_auth(self): 205 | response = request("http://httpbingo.org/basic-auth/user/passwd", basic_auth=("user", "passwd")) 206 | self.assertEqual(response.json["authorized"], True) 207 | 208 | def test_should_handle_gzip(self): 209 | response = request("http://httpbingo.org/gzip", headers={"Accept-Encoding": "gzip"}) 210 | self.assertEqual(response.json["gzipped"], True) 211 | 212 | def test_should_handle_gzip_error(self): 213 | response = request("http://httpbingo.org/status/418", headers={"Accept-Encoding": "gzip"}) 214 | self.assertEqual(response.content, b"I'm a teapot!") 215 | 216 | def test_should_timeout(self): 217 | import socket 218 | 219 | with self.assertRaises((TimeoutError, socket.timeout)): 220 | response = request("http://httpbingo.org/delay/3", timeout=1) 221 | 222 | def test_should_handle_head_requests(self): 223 | response = request("http://httpbingo.org/head", method="HEAD") 224 | self.assertTrue(response.content == b"") 225 | -------------------------------------------------------------------------------- /djbs.py: -------------------------------------------------------------------------------- 1 | import random 2 | import secrets 3 | import sys 4 | from io import BytesIO 5 | from pathlib import Path 6 | from subprocess import run 7 | from zipfile import ZipFile 8 | 9 | from args import parse_args 10 | from thttp import request 11 | 12 | from textwrap import dedent 13 | 14 | 15 | EXAMPLE_MANAGEMENT_COMMAND = """# Example management command 16 | # https://docs.djangoproject.com/en/4.0/howto/custom-management-commands/ 17 | 18 | from django.core.management.base import BaseCommand, CommandError 19 | 20 | 21 | class Command(BaseCommand): 22 | help = "An example management command created by djbs" 23 | 24 | def handle(self, *args, **options): 25 | self.stdout.write("Configure your management commands here...") 26 | raise CommandError("Management command not implemented")""" 27 | 28 | 29 | def create_project_directory(dir): 30 | dir.mkdir() 31 | return dir 32 | 33 | 34 | def django_install(dir): 35 | run(["pipenv install django"], shell=True, check=True, cwd=dir) 36 | 37 | 38 | def django_startproject(dir, project_name): 39 | run( 40 | [f"pipenv run django-admin startproject {project_name} ."], 41 | shell=True, 42 | check=True, 43 | cwd=dir, 44 | ) 45 | 46 | 47 | def django_secret_key_in_env(dir, project_name): 48 | settings = open(dir / project_name / "settings.py", "r").read().splitlines() 49 | 50 | for i, l in enumerate(settings): 51 | if l.startswith("from pathlib import Path"): 52 | settings[i] = "import os\nfrom pathlib import Path" 53 | 54 | if l.startswith("SECRET_KEY ="): 55 | settings[i] = f'SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "totally-insecure")' 56 | 57 | if l.startswith("DEBUG ="): 58 | settings[i] = ( 59 | l 60 | + "\n" 61 | + """if not DEBUG and SECRET_KEY == "totally-insecure": 62 | raise Exception("Do not run with the default secret key in production")""" 63 | ) 64 | 65 | with open(dir / project_name / "settings.py", "w") as f: 66 | f.write("\n".join(settings)) 67 | 68 | with open(dir / ".env", "w") as f: 69 | f.write(f"DJANGO_SECRET_KEY={secrets.token_urlsafe(40)}") 70 | 71 | 72 | def django_set_allowed_hosts(dir, project_name, domain): 73 | settings = open(dir / project_name / "settings.py", "r").read() 74 | settings = settings.replace("ALLOWED_HOSTS = []", f'ALLOWED_HOSTS = ["{domain}", "localhost"]') 75 | 76 | with open(dir / project_name / "settings.py", "w") as f: 77 | f.write(settings) 78 | 79 | 80 | def django_startapp(dir, app_name): 81 | run( 82 | [f"pipenv run python manage.py startapp {app_name}"], 83 | shell=True, 84 | check=True, 85 | cwd=dir, 86 | ) 87 | 88 | 89 | def django_run_migrations(dir, app_name): 90 | run([f"pipenv run python manage.py migrate"], shell=True, check=True, cwd=dir) 91 | 92 | 93 | def django_add_app_to_installed_apps(dir, project_name, app): 94 | settings = open(dir / project_name / "settings.py", "r").read() 95 | settings = settings.replace('"django.contrib.staticfiles",', f'"django.contrib.staticfiles",\n "{app}",') 96 | 97 | with open(dir / project_name / "settings.py", "w") as f: 98 | f.write(settings) 99 | 100 | 101 | def django_add_base_template(dir, project_name, app): 102 | p = dir / app / "templates" 103 | p.mkdir() 104 | 105 | basehtml = request("https://basehtml.xyz").content.decode() 106 | basehtml = basehtml.split("