diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100755 index 0000000..ec6e693 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,69 @@ +name: Build and Publish + +on: + release: + types: + - created + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install dependencies + run: | + pip install -U wheel pip setuptools + pip install -e '.[test]' + - name: Lint and Test + run: | + python setup.py test + deploy-sdist: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install dependencies + run: | + pip install -U wheel pip setuptools + pip install -e '.[publish]' + - name: Build and publish sdist + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python setup.py sdist + twine check dist/* + twine upload --non-interactive --skip-existing dist/* + deploy-wheels: + needs: test + strategy: + matrix: + platform: [macos-latest, windows-latest] + pyversion: [3.6, 3.7, 3.8] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.pyversion }} + - name: Install dependencies + run: | + pip install -U wheel pip setuptools + pip install -e '.[publish]' + - name: Build and publish wheel + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + python setup.py bdist_wheel + twine check dist/*.whl + twine upload --non-interactive --skip-existing dist/*.whl diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100755 index 0000000..4f8f539 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Integration Tests + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + strategy: + matrix: + platform: [ubuntu-latest, macos-latest, windows-latest] + pyversion: [3.6, 3.7, 3.8] + fail-fast: false + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.pyversion }} + - name: Install dependencies + run: | + pip install -U wheel pip setuptools + pip install -e '.[test]' + - name: Test + run: | + python setup.py test diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..7b334d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +.pytest_cache/ +.coverage + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# misc +.python-version +__pypackages__/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100755 index 0000000..2c33428 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Divya Jain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100755 index 0000000..88f0475 --- /dev/null +++ b/README.rst @@ -0,0 +1,13 @@ +======== +importit +======== + +Import python code from anywhere. + + +Installation +------------ + +.. code-block:: shell + + pip install importit diff --git a/importit/__init__.py b/importit/__init__.py new file mode 100755 index 0000000..de75d16 --- /dev/null +++ b/importit/__init__.py @@ -0,0 +1,3 @@ +from .importer import import_code, import_local_file, import_remote_file, import_gist + +__all__ = ["import_code", "import_local_file", "import_remote_file", "import_gist"] diff --git a/importit/importer.py b/importit/importer.py new file mode 100755 index 0000000..ded69ef --- /dev/null +++ b/importit/importer.py @@ -0,0 +1,101 @@ +"""Defines various importer functions.""" + +import sys +from json import loads +from .util.file import get_local_file_content, get_remote_file_content +from .util.module import create_empty_module, create_module_from_code + +from types import ModuleType + + +def import_code(module_name: str, source_code: str, origin: str = None) -> ModuleType: + """Imports python code as a module. + + Args: + module_name: The name to be given to imported module. + source_code: The code to be imported. + origin: The origin of the code. Defaults to None. + + Returns: + Python code imported as a module. + + Raises: + ImportError: Raised when the code can't be imported. + """ + module = create_module_from_code(module_name, source_code, origin) + sys.modules[module_name] = module + + return module + + +def import_local_file(module_name: str, file_path: str) -> ModuleType: + """Imports a local file as a module. + + Args: + module_name: The name to be given to the module. + file_path: The path of the file to be imported. + + Returns: + File from the path imported as a module. + + Raises: + ImportError: Raised when the file can't be imported. + """ + source_code = get_local_file_content(file_path) + + module = create_module_from_code(module_name, source_code, origin=file_path) + sys.modules[module_name] = module + + return module + + +def import_remote_file(module_name: str, url: str) -> ModuleType: + """Imports a remote file as a module. + + Args: + module_name: The name to be given to imported module. + url: The url of file to be imported. + + Returns: + File from the URL imported as a module. + + Raises: + ImportError: Raised when the file can't be imported. + """ + source_code = get_remote_file_content(url) + + module = create_module_from_code(module_name, source_code, origin=url) + sys.modules[module_name] = module + + return module + + +def import_gist(module_name: str, gist_id: str) -> ModuleType: + """Imports a gist as a module. + + Args: + module_name: The name to be given to imported module. + gist_id: The id of the gist to be imported. + + Returns: + Gist imported as a module. + + Raises: + ImportError: Raised when the gist can't be imported. + """ + gist = loads(get_remote_file_content("https://api.github.com/gists/" + gist_id)) + + module = create_empty_module(module_name) + + submodules = { + filename[:-3]: create_module_from_code( + filename[:-3], gist_file["content"], gist_file["raw_url"] + ) + for filename, gist_file in gist["files"].items() + if filename.endswith(".py") + } + + module.__dict__.update(submodules) + sys.modules[module_name] = module + + return module diff --git a/importit/util/__init__.py b/importit/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/importit/util/file.py b/importit/util/file.py new file mode 100644 index 0000000..5a239ce --- /dev/null +++ b/importit/util/file.py @@ -0,0 +1,29 @@ +from urllib.request import urlopen + + +def get_local_file_content(file_path: str) -> str: + """Gets the contents of a local file. + + Args: + file_path: The path of the file. + + Returns: + The content fetched from the local file. + """ + with open(file_path, "r") as opened_file: + content: str = opened_file.read() + return content + + +def get_remote_file_content(url: str) -> str: + """Gets the contents of a remote file. + + Args: + url: The url of the file. + + Returns: + The content fetched from remote file. + """ + with urlopen(url) as loaded_file: + content: str = loaded_file.read().decode("utf-8") + return content diff --git a/importit/util/module.py b/importit/util/module.py new file mode 100755 index 0000000..5fadf08 --- /dev/null +++ b/importit/util/module.py @@ -0,0 +1,36 @@ +from importlib.util import spec_from_loader, module_from_spec + +from types import ModuleType + + +def create_empty_module(module_name: str, origin: str = None) -> ModuleType: + """Creates a blank module. + + Args: + module_name: The name to be given to the module. + origin: The origin of the module. Defaults to None. + + Returns: + A blank module. + """ + spec = spec_from_loader(module_name, loader=None, origin=origin) + module = module_from_spec(spec) + return module + + +def create_module_from_code( + module_name: str, source_code: str, origin: str = None +) -> ModuleType: + """Creates a module from python code. + + Args: + module_name: The name to be given to the module. + source_code: The source code for the module. + origin: The origin of the module. Defaults to None. + + Returns: + A new module from the source code. + """ + module = create_empty_module(module_name, origin) + exec(source_code, module.__dict__) + return module diff --git a/setup.cfg b/setup.cfg new file mode 100755 index 0000000..6b27751 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,63 @@ +[metadata] +name = importit +version = 0.1.0 +description = Import python code from anywhere. +long_description = file: README.rst +long_description_content_type = text/x-rst +license = MIT +author = Divya Jain +author_email = dkj@somaiya.edu +url = https://github.com/divykj/importit +download_urls = https://pypi.org/project/importit +project_urls = + Documentation = https://github.com/divykj/importit + Code = https://github.com/divykj/importit + Issue tracker = https://github.com/divykj/importit/issues +classifiers = + Development Status :: 4 - Beta + License :: OSI Approved :: MIT License + Intended Audience :: Developers + Topic :: Utilities + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: Implementation :: CPython + +[options] +zip_safe = False +python_requires = >=3.6 +packages = + importit + +[options.extras_require] +test = + flake8 + mypy + pytest + pytest-cov +publish = + twine + +[flake8] +max_complexity = 10 +# Matching black max-line-length of 88 +max-line-length = 88 +extend-ignore = + # See https://github.com/PyCQA/pycodestyle/issues/373 + E203 + +[mypy] +disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_incomplete_defs = True +disallow_untyped_decorators = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_return_any = True +warn_unreachable = True +pretty = True diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..8f18973 --- /dev/null +++ b/setup.py @@ -0,0 +1,61 @@ +from distutils.cmd import Command +from distutils.log import INFO +from distutils.errors import DistutilsError +from setuptools import setup +from subprocess import run, CalledProcessError + + +class TestCommand(Command): + """A custom command to run lint, tests, and coverage.""" + + description = "run lint, tests, and coverage on Python source files" + user_options = [ + ("fail-fast", None, "Stop after first failed command."), + ("no-lint", None, "Don't run linter (flake8)."), + ("no-typecheck", None, "Don't run static typechecker (mypy)."), + ("no-tests", None, "Don't run tests (pytest)."), + ("no-coverage", None, "Don't generate coverage report."), + ] + + def initialize_options(self): + """Set default values for options.""" + self.fail_fast = False + self.no_lint = False + self.no_typecheck = False + self.no_tests = False + self.no_coverage = False + + def finalize_options(self): + self.fail_fast = bool(self.fail_fast) + self.no_lint = bool(self.no_lint) + self.no_typecheck = bool(self.no_typecheck) + self.no_tests = bool(self.no_tests) + self.no_coverage = bool(self.no_coverage) + + def run(self): + """Run command.""" + commands = [] + if not self.no_lint: + commands.append(["flake8"]) + if not self.no_typecheck: + commands.append(["mypy", "importit"]) + if not self.no_tests: + commands.append( + ["pytest"] if self.no_coverage else ["pytest", "--cov=importit"] + ) + any_command_failed = False + for command in commands: + self.announce("running command: {}".format(" ".join(command)), level=INFO) + try: + run(command, check=True) + except CalledProcessError: + any_command_failed = True + if self.fail_fast: + break + + if any_command_failed: + raise DistutilsError("tests failed") + + +if __name__ == "__main__": + setup(cmdclass={"test": TestCommand}) diff --git a/tests/test_importer/test_import_code.py b/tests/test_importer/test_import_code.py new file mode 100755 index 0000000..f15fc35 --- /dev/null +++ b/tests/test_importer/test_import_code.py @@ -0,0 +1,22 @@ +from importit.importer import import_code + +import pytest + + +@pytest.fixture +def test_hello_code(): + return "def say_hello():\n return 'hello'" + + +def test_import(test_hello_code): + import_code("hello", test_hello_code) + + +def test_import_name(test_hello_code): + hello = import_code("hello", test_hello_code) + assert hello.__name__ == "hello" + + +def test_import_function(test_hello_code): + hello = import_code("hello", test_hello_code) + assert hello.say_hello() == "hello" diff --git a/tests/test_importer/test_import_gist.py b/tests/test_importer/test_import_gist.py new file mode 100755 index 0000000..66649d1 --- /dev/null +++ b/tests/test_importer/test_import_gist.py @@ -0,0 +1,47 @@ +from importit.importer import import_gist + +import pytest + + +def mock_gist_content(url): + return """{ + "files": { + "hello.py": { + "raw_url": "https://gist.githubusercontent.com/mock-gist/hello.py", + "truncated": false, + "content": "def say_hello():\\n return \\"hello\\"" + }, + "hello_again.py": { + "raw_url": "https://gist.githubusercontent.com/mock-gist/hello_again.py", + "truncated": false, + "content": "def say_hello_again():\\n return \\"hello again\\"" + } + } +}""" + + +@pytest.fixture +def test_gist_id(monkeypatch): + monkeypatch.setattr("importit.importer.get_remote_file_content", mock_gist_content) + return "mock-gist-id" + + +def test_import(test_gist_id): + import_gist("hellos", test_gist_id) + + +def test_module_name(test_gist_id): + hellos = import_gist("hellos", test_gist_id) + assert hellos.__name__ == "hellos" + + +def test_submodule_names(test_gist_id): + hellos = import_gist("hellos", test_gist_id) + assert hellos.hello.__name__ == "hello" + assert hellos.hello_again.__name__ == "hello_again" + + +def test_submodules(test_gist_id): + hellos = import_gist("hellos", test_gist_id) + assert hellos.hello.say_hello() == "hello" + assert hellos.hello_again.say_hello_again() == "hello again" diff --git a/tests/test_importer/test_import_local_file.py b/tests/test_importer/test_import_local_file.py new file mode 100644 index 0000000..d2c364b --- /dev/null +++ b/tests/test_importer/test_import_local_file.py @@ -0,0 +1,29 @@ +from importit.importer import import_local_file + +import pytest + + +def mock_local_file_content(url): + return "def say_hello():\n return 'hello'" + + +@pytest.fixture +def mock_hello_file(monkeypatch): + monkeypatch.setattr( + "importit.importer.get_local_file_content", mock_local_file_content + ) + return "/home/user/dummy.py" + + +def test_import(mock_hello_file): + import_local_file("hello", mock_hello_file) + + +def test_import_name(mock_hello_file): + hello = import_local_file("hello", mock_hello_file) + assert hello.__name__ == "hello" + + +def test_import_function(mock_hello_file): + hello = import_local_file("hello", mock_hello_file) + assert hello.say_hello() == "hello" diff --git a/tests/test_importer/test_import_remote_file.py b/tests/test_importer/test_import_remote_file.py new file mode 100755 index 0000000..1c8c050 --- /dev/null +++ b/tests/test_importer/test_import_remote_file.py @@ -0,0 +1,29 @@ +from importit.importer import import_remote_file + +import pytest + + +def mock_remote_file_content(url): + return "def say_hello():\n return 'hello'" + + +@pytest.fixture +def mock_hello_file(monkeypatch): + monkeypatch.setattr( + "importit.importer.get_remote_file_content", mock_remote_file_content + ) + return "https://mock_url.com/hello.py" + + +def test_import(mock_hello_file): + import_remote_file("hello", mock_hello_file) + + +def test_import_name(mock_hello_file): + hello = import_remote_file("hello", mock_hello_file) + assert hello.__name__ == "hello" + + +def test_import_function(mock_hello_file): + hello = import_remote_file("hello", mock_hello_file) + assert hello.say_hello() == "hello"