Repository: aio-libs/aiosmtpd (python)

Issue:
Can't install Version 1.2.4 - No module named 'public'
**System:** CentOS 7
**Python Version:** 3.6 (from Software Collection)
**PIP Version:** 21.0.1 
**Virtualenv:** yes

**Description:**

When I try to install version 1.2.4 from pypi, the install will be discarded and version 1.2.2 is insalled instead.

The reason is, that the module atpuplic is not installed. So the error "ModuleNotFoundError: No module named 'public'" is raised.


**Stacktrace:**

```
(testenv) [root@78e76264529c /]# pip install -i https://pypi.org/simple aiosmtpd
Collecting aiosmtpd
  Downloading aiosmtpd-1.2.4.tar.gz (83 kB)
     |################################| 83 kB 340 kB/s 
  Installing build dependencies ... done
  Getting requirements to build wheel ... error
  ERROR: Command errored out with exit status 1:
   command: /testenv/bin/python /testenv/lib64/python3.6/site-packages/pip/_vendor/pep517/_in_process.py get_requires_for_build_wheel /tmp/tmpxbr7mohf
       cwd: /tmp/pip-install-s4res86q/aiosmtpd_1b7979a5651c4ef48a1f5c635ab706a8
  Complete output (55 lines):
  Traceback (most recent call last):
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 387, in _parse_attr
      return getattr(StaticModule(module_name), attr_name)
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 26, in __init__
      src = strm.read()
    File "/opt/rh/rh-python36/root/usr/lib64/python3.6/encodings/ascii.py", line 26, in decode
      return codecs.ascii_decode(input, self.errors)[0]
  UnicodeDecodeError: 'ascii' codec can't decode byte 0xc2 in position 30439: ordinal not in range(128)
  
  During handling of the above exception, another exception occurred:
  
  Traceback (most recent call last):
    File "/testenv/lib64/python3.6/site-packages/pip/_vendor/pep517/_in_process.py", line 280, in <module>
      main()
    File "/testenv/lib64/python3.6/site-packages/pip/_vendor/pep517/_in_process.py", line 263, in main
      json_out['return_val'] = hook(**hook_input['kwargs'])
    File "/testenv/lib64/python3.6/site-packages/pip/_vendor/pep517/_in_process.py", line 114, in get_requires_for_build_wheel
      return hook(config_settings)
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/build_meta.py", line 150, in get_requires_for_build_wheel
      config_settings, requirements=['wheel'])
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/build_meta.py", line 130, in _get_build_requires
      self.run_setup()
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/build_meta.py", line 145, in run_setup
      exec(compile(code, __file__, 'exec'), locals())
    File "setup.py", line 4, in <module>
      setup()
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/__init__.py", line 153, in setup
      return distutils.core.setup(**attrs)
    File "/opt/rh/rh-python36/root/usr/lib64/python3.6/distutils/core.py", line 121, in setup
      dist.parse_config_files()
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/dist.py", line 681, in parse_config_files
      ignore_option_errors=ignore_option_errors)
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 157, in parse_configuration
      meta.parse()
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 463, in parse
      section_parser_method(section_options)
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 436, in parse_section
      self[name] = value
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 220, in __setitem__
      value = parser(value)
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 552, in _parse_version
      version = self._parse_attr(value, self.package_dir)
    File "/tmp/pip-build-env-1ki9o5ff/overlay/lib/python3.6/site-packages/setuptools/config.py", line 390, in _parse_attr
      module = importlib.import_module(module_name)
    File "/opt/rh/rh-python36/root/usr/lib64/python3.6/importlib/__init__.py", line 126, in import_module
      return _bootstrap._gcd_import(name[level:], package, level)
    File "<frozen importlib._bootstrap>", line 994, in _gcd_import
    File "<frozen importlib._bootstrap>", line 971, in _find_and_load
    File "<frozen importlib._bootstrap>", line 955, in _find_and_load_unlocked
    File "<frozen importlib._bootstrap>", line 665, in _load_unlocked
    File "<frozen importlib._bootstrap_external>", line 678, in exec_module
    File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
    File "/tmp/pip-install-s4res86q/aiosmtpd_1b7979a5651c4ef48a1f5c635ab706a8/aiosmtpd/smtp.py", line 15, in <module>
      from public import public
  ModuleNotFoundError: No module named 'public'
  ----------------------------------------
WARNING: Discarding https://files.pythonhosted.org/packages/68/00/736583da7ce52b0ff3e8ce9045d2c1a183d1877a59a4362be50f49e49b03/aiosmtpd-1.2.4.tar.gz#sha256=34cea0ecd0495f752e1c4bfe5f541676d7aab29674390c007834a63fe7ba0bda (from https://pypi.org/simple/aiosmtpd/) (requires-python:~=3.6). Command errored out with exit status 1: /testenv/bin/python /testenv/lib64/python3.6/site-packages/pip/_vendor/pep517/_in_process.py get_requires_for_build_wheel /tmp/tmpxbr7mohf Check the logs for full command output.
  Downloading aiosmtpd-1.2.2.tar.gz (170 kB)
     |################################| 170 kB 12.4 MB/s 
Collecting atpublic
  Downloading atpublic-2.1.2.tar.gz (16 kB)
Collecting typing_extensions
  Downloading typing_extensions-3.7.4.3-py3-none-any.whl (22 kB)
Using legacy 'setup.py install' for aiosmtpd, since package 'wheel' is not installed.
Using legacy 'setup.py install' for atpublic, since package 'wheel' is not installed.
Installing collected packages: typing-extensions, atpublic, aiosmtpd
    Running setup.py install for atpublic ... done
    Running setup.py install for aiosmtpd ... done
Successfully installed aiosmtpd-1.2.2 atpublic-2.1.2 typing-extensions-3.7.4.3
```

Pull request description:
FIX for "No module named 'public'" during installation
(Original PR notes moved into HTML comment)

## What do these changes do?

Move the `__version__` attribute from `smtp.py` to `__init__.py`

This is enough to prevent breakage when installing using `setuptools<46.4.0` (see [this PR](https://github.com/pypa/setuptools/pull/1753) which resulted in [this change for 46.4.0](https://setuptools.readthedocs.io/en/latest/history.html#v46-4-0))

Another benefit is that this new location is Semantically more correct -- version applies to _all_ aiosmtpd package, not just smtp.py

## Are there changes in behavior for the user?

Only if user actively searches for `__version__` in the source code of `smtp.py` (via regex, startswith, or AST) instead of importing that attribute from `smtp.py`

User that do `from aiosmtpd.smtp import __version__` will NOT be affected, though it will be better if the import is done `from aiosmtpd` instead.

## Related issue number

Fixes #241 (partially? Not sure if encoding error in the output posted in that issue is related to this...)

## Checklist

- [x] I think the code is well written
- [x] Unit tests for the changes exist
- [x] tox testenvs have been executed in the following environments:
  - [x] Windows 10 (via PyCharm tox runner): `(ALL)`
  - [x] Windows 10 (via PSCore 7.1.1): `(ALL)`
  - [x] Windows 10 (via Cygwin): `qa,py36-{nocov,cov}`
  - [x] Ubuntu 18.04 on WSL 1.0: `(ALL)` + `pypy3-{nocov,cov,diffcov}`
  - [x] FreeBSD 12.2 on VBox: `(ALL)` + `pypy3-{nocov,cov,diffcov}`
  - [x] OpenSUSE Leap 15.2 on VBox: `(ALL)` + `pypy3-{nocov,cov,diffcov}`
  - [x] AlmaLinux 8.3 Beta 1 on VBox: `(ALL)` + `pypy3-{nocov,cov,diffcov}`
- [x] Documentation reflects the changes
- [x] Add a news fragment into the `NEWS.rst` file

<!--
## What do these changes do?

Adding `setup_requires` solved the problem, when installing the package.
```
setup_requires =
    atpublic
    attrs
```


## Related issue number

Closes #241 
-->


Patch:
diff --git a/aiosmtpd/__init__.py b/aiosmtpd/__init__.py
index e69de29..05c9e9e 100644
--- a/aiosmtpd/__init__.py
+++ b/aiosmtpd/__init__.py
@@ -0,0 +1,4 @@
+# Copyright 2014-2021 The aiosmtpd Developers
+# SPDX-License-Identifier: Apache-2.0
+
+__version__ = "1.3.0a4"
diff --git a/aiosmtpd/docs/NEWS.rst b/aiosmtpd/docs/NEWS.rst
index 73eef8c..26a5697 100644
--- a/aiosmtpd/docs/NEWS.rst
+++ b/aiosmtpd/docs/NEWS.rst
@@ -19,6 +19,11 @@ Fixed/Improved
 * ``authenticator`` system improves on ``auth_callback`` by enabling the called function
   to see the SMTP Session and other info.
   (``auth_callback`` will be deprecated in 2.0)
+* ``__version__`` is now an attribute in ``__init__.py``,
+  and can be imported from the 'plain' ``aiosmtpd`` module.
+  (It gets reimported to ``aiosmtpd.smtp``,
+  so programs relying on ``aiosmtpd.smtp.__version__`` should still work.)
+  (Closes #241)
 
 
 1.2.4 (2021-01-24)
diff --git a/aiosmtpd/docs/conf.py b/aiosmtpd/docs/conf.py
index 4969686..1ce252b 100644
--- a/aiosmtpd/docs/conf.py
+++ b/aiosmtpd/docs/conf.py
@@ -12,17 +12,16 @@
 # All configuration values have a default; values that are commented out
 # serve to show the default.
 
-import sys
-import re
 import datetime
-
+import re
+import sys
 from pathlib import Path
 
+from aiosmtpd import __version__
+
 try:
     # noinspection PyPackageRequirements
-    from colorama import (  # pytype: disable=import-error
-        init as colorama_init,
-    )
+    from colorama import init as colorama_init  # pytype: disable=import-error
 
     colorama_init()
 except ImportError:
@@ -77,15 +76,6 @@ copyright = f"2015-{datetime.datetime.now().year}, {author}"
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 #
-__version__ = None
-with open("../smtp.py") as fp:
-    for line in fp:
-        m = RE__VERSION.match(line.strip())
-        if m:
-            __version__ = m.group("ver")
-            break
-if __version__ is None:
-    raise RuntimeError("No __version__ found in aiosmtpd/smtp.py!")
 release = __version__
 version = __version__
 
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index f86abdc..cf263f6 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -13,6 +13,7 @@ import binascii
 import collections
 import asyncio.sslproto as sslproto
 
+from aiosmtpd import __version__
 from base64 import b64decode, b64encode
 from email._header_value_parser import get_addr_spec, get_angle_addr
 from email.errors import HeaderParseError
@@ -66,8 +67,8 @@ __all__ = [
     "AuthCallbackType",
     "AuthMechanismType",
     "MISSING",
+    "__version__",
 ]  # Will be added to by @public
-__version__ = '1.3.0a3'
 __ident__ = 'Python SMTP {}'.format(__version__)
 log = logging.getLogger('mail.log')
 
diff --git a/setup.cfg b/setup.cfg
index 0707662..ab14d23 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = aiosmtpd
-version = attr: aiosmtpd.smtp.__version__
+version = attr: aiosmtpd.__version__
 description = aiosmtpd - asyncio based SMTP server
 long_description =
     This is a server for SMTP and related protocols, similar in utility to the


Test patch:
diff --git a/aiosmtpd/tests/test_server.py b/aiosmtpd/tests/test_server.py
index 94ea6ab..1262bfe 100644
--- a/aiosmtpd/tests/test_server.py
+++ b/aiosmtpd/tests/test_server.py
@@ -8,9 +8,10 @@ import os
 import socket
 import unittest
 
+from aiosmtpd import __version__ as init_version
 from aiosmtpd.controller import asyncio, Controller, _FakeServer
 from aiosmtpd.handlers import Sink
-from aiosmtpd.smtp import SMTP as Server
+from aiosmtpd.smtp import SMTP as Server, __version__ as smtp_version
 from contextlib import ExitStack
 from functools import wraps
 from smtplib import SMTP
@@ -203,3 +204,9 @@ class TestFactory(unittest.TestCase):
             self.assertIsNone(cont._thread_exception)
             excm = str(cm.exception)
             self.assertEqual("Unknown Error, failed to init SMTP server", excm)
+
+
+class TestCompat(unittest.TestCase):
+
+    def test_version(self):
+        assert smtp_version is init_version