Repository: joke2k/django-environ
Pull request #171 (2018-02-11 10:26:08)

Warn about ignored netloc component in SQLite URLs
I got bitten by an incorrect number of slashes in SQLite database, which resulted in the filename being treated as netloc and the database being created in-memory for every run. Given that netlocs don't mean much for SQLite anyway, I figured it could generate a warning.

<!-- Reviewable:start -->
---
This change is [<img src="https://reviewable.io/review_button.svg" height="34" align="absmiddle" alt="Reviewable"/>](https://reviewable.io/reviews/joke2k/django-environ/171)
<!-- Reviewable:end -->



Patch:
diff --git a/.travis.yml b/.travis.yml
index b973908..d43407b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -12,6 +12,7 @@ env:
    - DJANGO="Django>=1.9,<1.10"
    - DJANGO="Django>=1.10,<1.11"
    - DJANGO="Django>=1.11,<2.0"
+   - DJANGO="Django>=2.0,<2.1"
    - DJANGO="https://github.com/django/django/archive/master.tar.gz"
 install:
  # The default pip doesn't properly support pre-release version specifiers.
@@ -31,8 +32,17 @@ matrix:
   include:
     - python: "3.3"
       env: DJANGO="Django>=1.8,<1.9"
+    # Django 1.11+ supports Python 3.6
+    - python: "3.6"
+      env: DJANGO="Django>=1.11,<2.0"
+    - python: "3.6"
+      env: DJANGO="Django>=2.0,<2.1"
   exclude:
-    # Django master dropped support for Python 2.
+    # Django 2.0+ dropped support for Python 2.
+    - python: "2.7"
+      env: DJANGO="Django>=2.0,<2.1"
+    - python: "pypy"
+      env: DJANGO="Django>=2.0,<2.1"
     - python: "2.7"
       env: DJANGO="https://github.com/django/django/archive/master.tar.gz"
     - python: "pypy"
diff --git a/README.rst b/README.rst
index 290fecd..18dfd1f 100644
--- a/README.rst
+++ b/README.rst
@@ -187,6 +187,7 @@ Supported Types
     -  SQLITE: sqlite://
     -  SQLITE with SPATIALITE for GeoDjango: spatialite://
     -  Oracle: oracle://
+    -  MSSQL: mssql://
     -  PyODBC: pyodbc://
     -  Redshift: redshift://
     -  LDAP: ldap://
@@ -249,7 +250,6 @@ In order to set email configuration for django you can use this code:
 
     vars().update(EMAIL_CONFIG)
 
-
 SQLite urls
 -----------
 
@@ -258,6 +258,23 @@ and using the "file" portion as the filename of the database.
 This has the effect of four slashes being present for an absolute
 file path: sqlite:////full/path/to/your/database/file.sqlite.
 
+Nested lists
+------------
+
+Some settings such as Django's ``ADMINS`` make use of nested lists. You can use something like this to handle similar cases.
+
+.. code-block:: python
+
+    # DJANGO_ADMINS=John:john@admin.com,Jane:jane@admin.com
+    ADMINS = [x.split(':') for x in env.list('DJANGO_ADMINS')] 
+
+    # or use more specific function
+
+    from email.utils import getaddresses
+
+    # DJANGO_ADMINS=Full Name <email-with-name@example.com>,anotheremailwithoutname@example.com
+    ADMINS = getaddresses([env('DJANGO_ADMINS')])
+
 
 Tests
 =====
diff --git a/appveyor.yml b/appveyor.yml
index e885962..6f0d1bd 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -4,14 +4,42 @@ environment:
   PYTHONIOENCODING: "utf-8"
   matrix:
     - PYTHON: "C:/Python27"
-    - PYTHON: "C:/Python34"
+      DJANGO_VERSION: "Django>=1.8,<1.9"
+
+    - PYTHON: "C:/Python27"
+      DJANGO_VERSION: "Django>=1.9,<1.10"
+
+    - PYTHON: "C:/Python27"
+      DJANGO_VERSION: "Django>=1.10,<1.11"
+
+    - PYTHON: "C:/Python27"
+      DJANGO_VERSION: "Django>=1.11,<2.0"
+
+    - PYTHON: "C:/Python35"
+      DJANGO_VERSION: "Django>=1.8,<1.9"
+
+    - PYTHON: "C:/Python35"
+      DJANGO_VERSION: "Django>=1.9,<1.10"
+
+    - PYTHON: "C:/Python35"
+      DJANGO_VERSION: "Django>=1.10,<1.11"
+
+    - PYTHON: "C:/Python35"
+      DJANGO_VERSION: "Django>=1.11,<2.0"
+
+    - PYTHON: "C:/Python35"
+      DJANGO_VERSION: "Django>=2.0,<2.1"
+
 init:
   - "ECHO %PYTHON%"
   - ps: "ls C:/Python*"
 install:
-  - ps: (new-object net.webclient).DownloadFile('https://bootstrap.pypa.io/get-pip.py', 'C:/get-pip.py')
+  - ps: |
+      [Net.ServicePointManager]::SecurityProtocol = 'Tls11, Tls12'
+      (new-object net.webclient).DownloadFile('https://bootstrap.pypa.io/get-pip.py', 'C:/get-pip.py')
   - "%PYTHON%/python.exe C:/get-pip.py"
+  - "%PYTHON%/Scripts/pip.exe install \"%DJANGO_VERSION%\""
   - "%PYTHON%/Scripts/pip.exe install -e ."
 test_script:
   - "%PYTHON%/Scripts/pip.exe --version"
-  - "%PYTHON%/python.exe setup.py test"
\ No newline at end of file
+  - "%PYTHON%/python.exe setup.py test"
diff --git a/environ/environ.py b/environ/environ.py
index 1365a29..4eacf89 100644
--- a/environ/environ.py
+++ b/environ/environ.py
@@ -78,6 +78,7 @@ class Env(object):
         'mysql2': 'django.db.backends.mysql',
         'mysql-connector': 'mysql.connector.django',
         'mysqlgis': 'django.contrib.gis.db.backends.mysql',
+        'mssql': 'sql_server.pyodbc',
         'oracle': 'django.db.backends.oracle',
         'pyodbc': 'sql_server.pyodbc',
         'redshift': 'django_redshift_backend',
@@ -146,6 +147,12 @@ class Env(object):
         """
         return self.get_value(var, cast=str, default=default)
 
+    def bytes(self, var, default=NOTSET, encoding='utf8'):
+        """
+        :rtype: bytes
+        """
+        return self.get_value(var, cast=str).encode(encoding)
+
     def bool(self, var, default=NOTSET):
         """
         :rtype: bool
@@ -375,10 +382,14 @@ class Env(object):
         path = url.path[1:]
         path = urllib.parse.unquote_plus(path.split('?', 2)[0])
 
-        # if we are using sqlite and we have no path, then assume we
-        # want an in-memory database (this is the behaviour of sqlalchemy)
-        if url.scheme == 'sqlite' and path == '':
-            path = ':memory:'
+        if url.scheme == 'sqlite':
+            if path == '':
+                # if we are using sqlite and we have no path, then assume we
+                # want an in-memory database (this is the behaviour of  sqlalchemy)
+                path = ':memory:'
+            if url.netloc:
+                warnings.warn(
+                    'SQLite URL contains host component %r, it will be ignored' % url.netloc, stacklevel=3)
         if url.scheme == 'ldap':
             path = '{scheme}://{hostname}'.format(scheme=url.scheme, hostname=url.hostname)
             if url.port:
@@ -415,8 +426,11 @@ class Env(object):
 
         if engine:
             config['ENGINE'] = engine
-        if url.scheme in Env.DB_SCHEMES:
-            config['ENGINE'] = Env.DB_SCHEMES[url.scheme]
+        else:
+            config['ENGINE'] = url.scheme
+
+        if config['ENGINE'] in Env.DB_SCHEMES:
+            config['ENGINE'] = Env.DB_SCHEMES[config['ENGINE']]
 
         if not config.get('ENGINE', False):
             warnings.warn("Engine not recognized from url: {0}".format(config))
@@ -627,7 +641,7 @@ class Env(object):
         logger.debug('Read environment variables from: {0}'.format(env_file))
 
         for line in content.splitlines():
-            m1 = re.match(r'\A([A-Za-z_0-9]+)=(.*)\Z', line)
+            m1 = re.match(r'\A(?:export )?([A-Za-z_0-9]+)=(.*)\Z', line)
             if m1:
                 key, val = m1.group(1), m1.group(2)
                 m2 = re.match(r"\A'(.*)'\Z", val)
diff --git a/setup.py b/setup.py
index 8a13843..e745a99 100644
--- a/setup.py
+++ b/setup.py
@@ -27,6 +27,7 @@ setup(name='django-environ',
           'Framework :: Django :: 1.9',
           'Framework :: Django :: 1.10',
           'Framework :: Django :: 1.11',
+          'Framework :: Django :: 2.0',
           'Programming Language :: Python',
           'Programming Language :: Python :: 2',
           'Programming Language :: Python :: 2.7',


Test patch:
diff --git a/environ/test.py b/environ/test.py
index 78a373f..1851619 100644
--- a/environ/test.py
+++ b/environ/test.py
@@ -3,6 +3,7 @@ import json
 import os
 import sys
 import unittest
+import warnings
 
 from django.core.exceptions import ImproperlyConfigured
 
@@ -18,6 +19,7 @@ class BaseTests(unittest.TestCase):
     SQLITE = 'sqlite:////full/path/to/your/database/file.sqlite'
     ORACLE_TNS = 'oracle://user:password@sid/'
     ORACLE = 'oracle://user:password@host:1521/sid'
+    CUSTOM_BACKEND = 'custom.backend://user:password@example.com:5430/database'
     REDSHIFT = 'redshift://user:password@examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com:5439/dev'
     MEMCACHE = 'memcache://127.0.0.1:11211'
     REDIS = 'rediscache://127.0.0.1:6379/1?client_class=django_redis.client.DefaultClient&password=secret'
@@ -25,6 +27,7 @@ class BaseTests(unittest.TestCase):
     JSON = dict(one='bar', two=2, three=33.44)
     DICT = dict(foo='bar', test='on')
     PATH = '/home/dev'
+    EXPORTED = 'exported var'
 
     @classmethod
     def generateData(cls):
@@ -51,12 +54,14 @@ class BaseTests(unittest.TestCase):
                     DATABASE_ORACLE_URL=cls.ORACLE,
                     DATABASE_ORACLE_TNS_URL=cls.ORACLE_TNS,
                     DATABASE_REDSHIFT_URL=cls.REDSHIFT,
+                    DATABASE_CUSTOM_BACKEND_URL=cls.CUSTOM_BACKEND,
                     CACHE_URL=cls.MEMCACHE,
                     CACHE_REDIS=cls.REDIS,
                     EMAIL_URL=cls.EMAIL,
                     URL_VAR=cls.URL,
                     JSON_VAR=json.dumps(cls.JSON),
-                    PATH_VAR=cls.PATH)
+                    PATH_VAR=cls.PATH,
+                    EXPORTED_VAR=cls.EXPORTED)
 
     def setUp(self):
         self._old_environ = os.environ
@@ -88,6 +93,9 @@ class EnvTests(BaseTests):
         self.assertTypeAndValue(str, 'bar', self.env('STR_VAR'))
         self.assertTypeAndValue(str, 'bar', self.env.str('STR_VAR'))
 
+    def test_bytes(self):
+        self.assertTypeAndValue(bytes, b'bar', self.env.bytes('STR_VAR'))
+
     def test_int(self):
         self.assertTypeAndValue(int, 42, self.env('INT_VAR', cast=int))
         self.assertTypeAndValue(int, 42, self.env.int('INT_VAR'))
@@ -215,6 +223,14 @@ class EnvTests(BaseTests):
         self.assertEqual(sqlite_config['ENGINE'], 'django.db.backends.sqlite3')
         self.assertEqual(sqlite_config['NAME'], '/full/path/to/your/database/file.sqlite')
 
+        custom_backend_config = self.env.db('DATABASE_CUSTOM_BACKEND_URL')
+        self.assertEqual(custom_backend_config['ENGINE'], 'custom.backend')
+        self.assertEqual(custom_backend_config['NAME'], 'database')
+        self.assertEqual(custom_backend_config['HOST'], 'example.com')
+        self.assertEqual(custom_backend_config['USER'], 'user')
+        self.assertEqual(custom_backend_config['PASSWORD'], 'password')
+        self.assertEqual(custom_backend_config['PORT'], 5430)
+
     def test_cache_url_value(self):
 
         cache_config = self.env.cache_url()
@@ -246,6 +262,9 @@ class EnvTests(BaseTests):
         root = self.env.path('PATH_VAR')
         self.assertTypeAndValue(Path, Path(self.PATH), root)
 
+    def test_exported(self):
+        self.assertEqual(self.EXPORTED, self.env('EXPORTED_VAR'))
+
 
 class FileEnvTests(EnvTests):
 
@@ -358,6 +377,15 @@ class DatabaseTestSuite(unittest.TestCase):
 
         self.assertEqual(url['ENGINE'], 'django.db.backends.sqlite3')
         self.assertEqual(url['NAME'], ':memory:')
+        
+    def test_memory_sqlite_url_warns_about_netloc(self):
+        url = 'sqlite://missing-slash-path'
+        with warnings.catch_warnings(record=True) as w:
+            url = Env.db_url_config(url)
+            self.assertEqual(url['ENGINE'], 'django.db.backends.sqlite3')
+            self.assertEqual(url['NAME'], ':memory:')
+            self.assertEqual(len(w), 1)
+            self.assertTrue(issubclass(w[0].category, UserWarning))
 
     def test_database_options_parsing(self):
         url = 'postgres://user:pass@host:1234/dbname?conn_max_age=600'
diff --git a/environ/test_env.txt b/environ/test_env.txt
index e33078b..a891138 100644
--- a/environ/test_env.txt
+++ b/environ/test_env.txt
@@ -28,3 +28,5 @@ INT_TUPLE=(42,33)
 DATABASE_ORACLE_TNS_URL=oracle://user:password@sid
 DATABASE_ORACLE_URL=oracle://user:password@host:1521/sid
 DATABASE_REDSHIFT_URL=redshift://user:password@examplecluster.abc123xyz789.us-west-2.redshift.amazonaws.com:5439/dev
+DATABASE_CUSTOM_BACKEND_URL=custom.backend://user:password@example.com:5430/database
+export EXPORTED_VAR="exported var"