diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..6ac3b93 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,5 @@ +coverage: + status: + project: + default: + threshold: 80% diff --git a/.gitignore b/.gitignore index b2fa7cc..c212e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ test.py .coverage.* tests/cli/data/chpok tests/cli/data/pok +.idea diff --git a/README.md b/README.md index fd9a586..5dd7852 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,15 @@ Thanks to this package, it is very easy to manage the lifecycle of packages. ## Table of contents - [**Quick start**](#quick-start) +- [**REPL mode**](#repl-mode) - [**Script launch mode**](#script-launch-mode) - - [**Special comment language**](#special-comment-language) - - [**Using multiple environments**](#using-multiple-environments) - [**Context manager mode**](#context-manager-mode) - [**Installing multiple packages**](#installing-multiple-packages) - [**Options**](#options) - [**Using an existing virtual environment**](#using-an-existing-virtual-environment) - [**Output and logging**](#output-and-logging) +- [**Special comment language**](#special-comment-language) +- [**Using multiple environments**](#using-multiple-environments) - [**How does it work?**](#how-does-it-work) @@ -39,7 +40,7 @@ Install [it](https://pypi.org/project/instld/): pip install instld ``` -And use the library in one of two ways: by running your script through it or by importing a context manager from there. +And use the library in one of three ways: by typing commands via REPL, by running your script through it or by importing a context manager from there. If you run the script [like this](#script-launch-mode), all dependencies will be automatically installed when the application starts and deleted when it stops: @@ -47,6 +48,8 @@ If you run the script [like this](#script-launch-mode), all dependencies will be instld script.py ``` +The [REPL mode](#repl-mode) works in a similar way, you just need to type `instld` in the console to enter it. + You can also call the [context manager](#context-manager-mode) from your code: ```python @@ -59,57 +62,39 @@ with instld('some_package'): Read more about each method, its capabilities and limitations below. -## Script launch mode +## REPL mode -You can use `instld` to run your script. To do this, you need to run a command like this in the console: +REPL mode is the fastest and easiest way to try out other people's libraries for your code. Just type this in your console: ```bash -instld script.py +instld ``` -The contents of the script will be executed in the same way as if you were running it through the `python script.py` command. If necessary, you can pass additional arguments to the command line, as if you are running a regular Python script. However, if your program has imports of any packages other than the built-in ones, they will be installed automatically. Installed packages are automatically cleaned up when you exit the program, so they don't leave any garbage behind. - - -### Special comment language - -When using script launch mode, you can specify additional parameters for each import inside your program. To do this, you need to write immediately after it (but always in the same line!) a comment that starts with "instld:", separating key and value pairs with commas. - -As example, if the name of the imported module and the package name are different, this code imports the `f` function from the [`fazy`](https://github.com/pomponchik/fazy) library version `0.0.3`: - -```python -import f # instld: version 0.0.3, package fazy +After that you will see a welcome message similar to this: -print(f('some string')) ``` +⚡ INSTLD REPL based on +Python 3.11.6 (main, Oct 2 2023, 13:45:54) [Clang 15.0.0 (clang-1500.0.40.1)] on darwin +Type "help", "copyright", "credits" or "license" for more information. -You can also specify only the version or only the package name in the comment, they do not have to be specified together. +>>> +``` +Enjoy the regular Python [interactive console mode](https://docs.python.org/3/tutorial/interpreter.html#interactive-mode)! Any libraries that you ask for will be installed within the session, and after exiting it, they will be deleted without a trace. You don't need to "clean up" anything after exiting the console. -### Using multiple environments +In this mode, a [special comment language](#special-comment-language) is fully supported. -The instld script launch mode provides a unique opportunity to use multiple virtual environments at the same time. +## Script launch mode -Firstly, you can run scripts in the main virtual environment, and it will work exactly as you expect: +You can use `instld` to run your script from a file. To do this, you need to run a command like this in the console: ```bash -python3 -m venv venv -source venv/bin/activate instld script.py ``` -When the "import" command is executed in your script, the package will first be searched in the activated virtual environment, and only then downloaded if it is not found there. Note that by default, the activated virtual environment is read-only. That is, it is assumed that you will install all the necessary libraries there before running your script. If you want to install packages in runtime in a specific virtual environment - read about the second method further. - -Secondly, you can specify the path to the virtual environment directly [in the comments](#special-comment-language) to a specific import using the `where` directive: - -```python -import something # instld: where path/to/the/venv -``` - -If the path you specified does not exist when you first run the script, it will be automatically created. Libraries installed in this way are not deleted when the script is stopped, therefore, starting from the second launch, the download is no longer required. +The contents of the script will be executed in the same way as if you were running it through the `python script.py` command. If necessary, you can pass additional arguments to the command line, as if you are running a regular Python script. However, if your program has imports of any packages other than the built-in ones, they will be installed automatically. Installed packages are automatically cleaned up when you exit the program, so they don't leave any garbage behind. -Note that the path to the virtual environment in this case should not contain spaces. In addition, there is no multiplatform way to specify directory paths using a comment. Therefore, it is not recommended to use paths consisting of more than one part. - -Since script launch mode uses a context manager to install packages "under the hood", you should also read about the features of installing packages in this way in the [corresponding section](#using-an-existing-virtual-environment). +In this mode, as in [REPL](#repl-mode), a [special comment language](#special-comment-language) is fully supported. ## Context manager mode @@ -174,7 +159,7 @@ with instld('flask==2.0.2') as context_1: > ⚠️ Keep in mind that although inter-thread isolation is used inside the library, working with contexts is not completely thread-safe. You can write code in such a way that two different contexts import different modules in separate threads at the same time. In this case, you may get paradoxical results. Therefore, it is recommended to additionally isolate with mutexes all cases where you import something from contexts in different threads. -### Options +## Options You can use [any options](https://pip.pypa.io/en/stable/cli/pip_install/) available for `pip`. To do this, you need to slightly change the name of the option, replacing the hyphens with underscores, and pass it as an argument to `instld`. Here is an example of how using the `--index-url` option will look like: @@ -284,6 +269,48 @@ with instld('flask', catch_output=True): The `INFO` [level](https://docs.python.org/3/library/logging.html#logging-levels) is used by default. For errors - `ERROR`. +## Special comment language + +When using script launch or REPL mode, you can specify additional parameters for each import inside your program. To do this, you need to write immediately after it (but always in the same line!) a comment that starts with "instld:", separating key and value pairs with commas. + +As example, if the name of the imported module and the package name are different, this code imports the `f` function from the [`fazy`](https://github.com/pomponchik/fazy) library version `0.0.3`: + +```python +import f # instld: version 0.0.3, package fazy + +print(f('some string')) +``` + +You can also specify only the version or only the package name in the comment, they do not have to be specified together. + + +## Using multiple environments + +The instld script launch mode and REPL mode provides a unique opportunity to use multiple virtual environments at the same time. + +Firstly, you can run scripts in the main virtual environment, and it will work exactly as you expect: + +```bash +python3 -m venv venv +source venv/bin/activate +instld script.py +``` + +When the "import" command is executed in your script, the package will first be searched in the activated virtual environment, and only then downloaded if it is not found there. Note that by default, the activated virtual environment is read-only. That is, it is assumed that you will install all the necessary libraries there before running your script. If you want to install packages in runtime in a specific virtual environment - read about the second method further. + +Secondly, you can specify the path to the virtual environment directly [in the comments](#special-comment-language) to a specific import using the `where` directive: + +```python +import something # instld: where path/to/the/venv +``` + +If the path you specified does not exist when you first run the script, it will be automatically created. Libraries installed in this way are not deleted when the script is stopped, therefore, starting from the second launch, the download is no longer required. + +Note that the path to the virtual environment in this case should not contain spaces. In addition, there is no multiplatform way to specify directory paths using a comment. Therefore, it is not recommended to use paths consisting of more than one part. + +Since script launch mode uses a context manager to install packages "under the hood", you should also read about the features of installing packages in this way in the [corresponding section](#using-an-existing-virtual-environment). + + ## How does it work? This package is essentially a wrapper for `venv` and `pip`. diff --git a/instld/cli/main.py b/instld/cli/main.py index 991ae3a..10fcd6a 100644 --- a/instld/cli/main.py +++ b/instld/cli/main.py @@ -1,5 +1,6 @@ import os import sys +import code import builtins import importlib import inspect @@ -8,13 +9,16 @@ from threading import RLock import instld -from instld.cli.parsing_comments.get_options_from_comments import get_options_from_comments +from instld.cli.parsing_comments.get_options_from_comments import get_options_from_comments_by_frame from instld.cli.parsing_arguments.get_python_file import get_python_file from instld.cli.traceback_cutting.cutting import set_cutting_excepthook +from instld.state_management.storage import state_storage, RunType +from instld.errors import CommentFormatError def main(): python_file = get_python_file() + state_storage.run_type = RunType.script with instld() as context: lock = RLock() @@ -49,13 +53,21 @@ def import_wrapper(name, *args, **kwargs): last_name = splitted_name[-1] current_frame = inspect.currentframe() - options = get_options_from_comments(current_frame.f_back) + options = get_options_from_comments_by_frame(current_frame.f_back) package_name = options.pop('package', base_name) if 'version' in options: package_name = f'{package_name}=={options.pop("version")}' + catch_output = options.pop('catch_output', 'no').lower() + if catch_output in ('yes', 'on', 'true'): + catch_output = True + elif catch_output in ('no', 'off', 'false'): + catch_output = False + else: + raise CommentFormatError('For option "catch_output" you can use the following values: "yes", "on", "true", "no", "off", "false".') + current_context = get_current_context(options.pop('where', None)) with lock: @@ -63,7 +75,7 @@ def import_wrapper(name, *args, **kwargs): try: result = __import__(name, *args, **kwargs) except (ModuleNotFoundError, ImportError) as e: - current_context.install(package_name) + current_context.install(package_name, catch_output=catch_output, **options) result = current_context.import_here(base_name) sys.modules[base_name] = result @@ -78,13 +90,38 @@ def import_wrapper(name, *args, **kwargs): return result - builtins.__import__ = import_wrapper + if python_file is None: + try: + import readline + except ImportError: + pass + + state_storage.run_type = RunType.REPL + builtins.__import__ = import_wrapper + + class REPL(code.InteractiveConsole): + def push(self, line): + state_storage.last_string = line + return super().push(line) + + + banner_strings = [ + '⚡ INSTLD REPL based on\n' + 'Python %s on %s\n' % (sys.version, sys.platform), + 'Type "help", "copyright", "credits" or "license" for more information.\n', + ] + banner = ''.join(banner_strings) + + REPL().interact(banner=banner) + - spec = importlib.util.spec_from_file_location('kek', os.path.abspath(python_file)) - module = importlib.util.module_from_spec(spec) - sys.modules['__main__'] = module - set_cutting_excepthook(4) - spec.loader.exec_module(module) + else: + builtins.__import__ = import_wrapper + spec = importlib.util.spec_from_file_location('kek', os.path.abspath(python_file)) + module = importlib.util.module_from_spec(spec) + sys.modules['__main__'] = module + set_cutting_excepthook(4) + spec.loader.exec_module(module) if __name__ == "__main__": diff --git a/instld/cli/parsing_arguments/get_python_file.py b/instld/cli/parsing_arguments/get_python_file.py index fe667dd..dc8b8c1 100644 --- a/instld/cli/parsing_arguments/get_python_file.py +++ b/instld/cli/parsing_arguments/get_python_file.py @@ -3,8 +3,5 @@ def get_python_file(): - if len(sys.argv) < 2: - print('usage: instld python_file.py [argv ...]', file=sys.stderr) - sys.exit(1) - - return sys.argv[1] + if len(sys.argv) >= 2: + return sys.argv[1] diff --git a/instld/cli/parsing_comments/get_comment_string.py b/instld/cli/parsing_comments/get_comment_string.py index 9db6f38..e22b114 100644 --- a/instld/cli/parsing_comments/get_comment_string.py +++ b/instld/cli/parsing_comments/get_comment_string.py @@ -1,32 +1,39 @@ from functools import lru_cache from instld.errors import InstallingPackageError +from instld.state_management.storage import state_storage, RunType +def get_comment_substring_from_string(string): + splitted_line = string.split('#') + right_part = splitted_line[1:] + right_part = '#'.join(right_part) + right_part = right_part.strip() + if right_part.startswith('instld:'): + right_part = right_part[7:].strip() + if right_part: + return right_part + else: + raise InstallingPackageError('An empty list of options in the comment.') + @lru_cache() def get_comment_string_from_file(line_number, file_name): try: with open(file_name, 'r') as file: for index, line in enumerate(file): if index + 1 == line_number: - splitted_line = line.split('#') - right_part = splitted_line[1:] - right_part = '#'.join(right_part) - right_part = right_part.strip() - if right_part.startswith('instld:'): - right_part = right_part[7:].strip() - if right_part: - return right_part - else: - raise InstallingPackageError('An empty list of options in the comment.') - break + return get_comment_substring_from_string(line) except (FileNotFoundError, OSError): return None -def get_comment_string(frame): - line_number = frame.f_lineno - code = frame.f_code - file_name = code.co_filename +def get_comment_string_by_frame(frame): + if state_storage.run_type == RunType.script: + line_number = frame.f_lineno + code = frame.f_code + file_name = code.co_filename + + return get_comment_string_from_file(line_number, file_name) - return get_comment_string_from_file(line_number, file_name) + elif state_storage.run_type == RunType.REPL: + return get_comment_substring_from_string(state_storage.last_string) diff --git a/instld/cli/parsing_comments/get_options_from_comments.py b/instld/cli/parsing_comments/get_options_from_comments.py index b668034..6ddb0fc 100644 --- a/instld/cli/parsing_comments/get_options_from_comments.py +++ b/instld/cli/parsing_comments/get_options_from_comments.py @@ -1,10 +1,8 @@ from instld.errors import InstallingPackageError -from instld.cli.parsing_comments.get_comment_string import get_comment_string +from instld.cli.parsing_comments.get_comment_string import get_comment_string_by_frame -def get_options_from_comments(frame): - comment_string = get_comment_string(frame) - +def get_options_from_comments(comment_string): result = {} if comment_string is not None: @@ -21,4 +19,11 @@ def get_options_from_comments(frame): option_value = splitted_option[1].strip().lower() result[option_name] = option_value + result.pop('doc', None) + result.pop('comment', None) + return result + +def get_options_from_comments_by_frame(frame): + comment_string = get_comment_string_by_frame(frame) + return get_options_from_comments(comment_string) diff --git a/instld/errors.py b/instld/errors.py index 5fa6a34..764e682 100644 --- a/instld/errors.py +++ b/instld/errors.py @@ -6,3 +6,6 @@ class RestartingCommandError(Exception): class RunningCommandError(Exception): pass + +class CommentFormatError(Exception): + pass diff --git a/instld/module/context.py b/instld/module/context.py index 16205d9..2d43b88 100644 --- a/instld/module/context.py +++ b/instld/module/context.py @@ -42,10 +42,10 @@ def new_path(self, module_name): yield sys.path = old_path - def install(self, *package_names, **options): + def install(self, *package_names, catch_output=False, **options): if not package_names: raise ValueError('You need to pass at least one package name.') options = convert_options(options) - with self.installer(package_names, options=options): + with self.installer(package_names, catch_output=catch_output, options=options): pass diff --git a/instld/module/context_manager.py b/instld/module/context_manager.py index fdda090..05c4e8d 100644 --- a/instld/module/context_manager.py +++ b/instld/module/context_manager.py @@ -52,6 +52,8 @@ def create_temp_directory(): new_error = InstallingPackageError(f'{str(e)} It occurred when installing one of the following packages: {", ".join(packages_names)}.') new_error.stdout = e.stdout new_error.stderr = e.stderr + #print('STDOUT', e.stdout) + #print('STDERR', e.stderr) raise new_error from e yield Context(where, logger, catch_output, options, partial(pip_context, logger=logger, runner=runner, catch_output=catch_output, where=directory)) diff --git a/instld/state_management/__init__.py b/instld/state_management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/instld/state_management/storage.py b/instld/state_management/storage.py new file mode 100644 index 0000000..c65cc26 --- /dev/null +++ b/instld/state_management/storage.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from enum import IntEnum, auto +from typing import Optional + + +class RunType(IntEnum): + script = auto() + REPL = auto() + module = auto() + +@dataclass +class StateStorage: + run_type: RunType = RunType.module + last_string: Optional[str] = None + + +state_storage = StateStorage() diff --git a/requirements_dev.txt b/requirements_dev.txt index 767efeb..a4b56c9 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -3,3 +3,4 @@ coverage==7.2.7 twine==4.0.2 wheel==0.40.0 pytest-timeout==2.1.0 +contextif==0.0.3 diff --git a/setup.py b/setup.py index a8f1e35..0c08f7c 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name='instld', - version='0.0.21', + version='0.0.22', author='Evgeniy Blinov', author_email='zheni-b@yandex.ru', description='The simplest package management', @@ -36,6 +36,7 @@ 'Topic :: Software Development :: Interpreters', 'Topic :: Utilities', 'Topic :: System :: Archiving :: Packaging', - 'Topic :: System :: Archiving :: Installation/Setup', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', ], ) diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 4546e7a..14cccb4 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -5,6 +5,7 @@ import shutil import pytest +from contextif import state @pytest.mark.timeout(180) @@ -43,15 +44,6 @@ def test_cli_where(main_runner): os.remove(script) -def test_run_command_without_arguments(main_runner): - for runner in (main_runner, subprocess.run): - result = runner(['instld'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=100, universal_newlines=True) - - assert result.returncode == 1 - assert result.stdout == '' - assert result.stderr == f'usage: instld python_file.py [argv ...]\n' - - def test_run_command_with_arguments(main_runner): strings = [ 'import json, sys', @@ -125,3 +117,46 @@ def test_exceptions_are_similar_with_just_python_command_2(): assert result_1.stderr == result_2.stderr os.remove(script) + + +@pytest.mark.skip(reason="Now it's not so actual.") +def test_install_package_from_another_repository(main_runner): + strings = [ + 'import super_test # instld: package super_test_project, version 0.0.1, index_url https://test.pypi.org/simple/, catch_output true', + 'print(super_test.function(2, 3))', + ] + + script = os.path.join('tests', 'cli', 'data', 'main.py') + with open(script, 'w') as file: + file.write('\n'.join(strings)) + + for runner in (subprocess.run, main_runner): + result = runner(['instld', script], stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=100, universal_newlines=True) + + result.check_returncode() + + assert result.stdout == '5\n' + + + os.remove(script) + + +def test_install_package_from_another_repository_only_command(): + strings = [ + 'import super_test # instld: package super_test_project, version 0.0.1, index_url https://test.pypi.org/simple/, catch_output true', + 'print(super_test.function(2, 3))', + ] + + script = os.path.join('tests', 'cli', 'data', 'main.py') + with open(script, 'w') as file: + file.write('\n'.join(strings)) + + for runner in (subprocess.run,): + result = runner(['instld', script], stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=100, universal_newlines=True) + + result.check_returncode() + + assert result.stdout == '5\n' + + + os.remove(script) diff --git a/tests/units/cli/parsing_comments/test_get_comment_string.py b/tests/units/cli/parsing_comments/test_get_comment_string.py index a86af74..89ff205 100644 --- a/tests/units/cli/parsing_comments/test_get_comment_string.py +++ b/tests/units/cli/parsing_comments/test_get_comment_string.py @@ -3,24 +3,32 @@ import pytest from instld.errors import InstallingPackageError -from instld.cli.parsing_comments.get_comment_string import get_comment_string +from instld.cli.parsing_comments.get_comment_string import get_comment_string_by_frame, get_comment_substring_from_string def test_get_comment_started_with_instld(): - comment = get_comment_string(inspect.currentframe()) # instld: lol kek cheburek + comment = get_comment_string_by_frame(inspect.currentframe()) # instld: lol kek cheburek assert comment == 'lol kek cheburek' def test_get_comment_not_started_with_instld(): - comment = get_comment_string(inspect.currentframe()) # lol kek cheburek + comment = get_comment_string_by_frame(inspect.currentframe()) # lol kek cheburek assert comment is None def test_get_comment_without_comment(): - comment = get_comment_string(inspect.currentframe()) + comment = get_comment_string_by_frame(inspect.currentframe()) assert comment is None def test_get_comment_wrong(): with pytest.raises(InstallingPackageError): - comment = get_comment_string(inspect.currentframe()) # instld: + comment = get_comment_string_by_frame(inspect.currentframe()) # instld: + + +def test_get_comment_substring_from_string(): + assert get_comment_substring_from_string('a + b # kek') is None + assert get_comment_substring_from_string('a + b # instld: lol kek') == 'lol kek' + + with pytest.raises(InstallingPackageError): + assert get_comment_substring_from_string('a + b # instld: ') diff --git a/tests/units/cli/parsing_comments/test_get_options_from_comments.py b/tests/units/cli/parsing_comments/test_get_options_from_comments.py index 41ed6ac..1d00917 100644 --- a/tests/units/cli/parsing_comments/test_get_options_from_comments.py +++ b/tests/units/cli/parsing_comments/test_get_options_from_comments.py @@ -3,11 +3,11 @@ import pytest from instld.errors import InstallingPackageError -from instld.cli.parsing_comments.get_options_from_comments import get_options_from_comments +from instld.cli.parsing_comments.get_options_from_comments import get_options_from_comments_by_frame def test_get_normal_options(): - options = get_options_from_comments(inspect.currentframe()) # instld: lol kek, cheburek mek + options = get_options_from_comments_by_frame(inspect.currentframe()) # instld: lol kek, cheburek mek assert isinstance(options, dict) assert len(options) == 2 @@ -18,15 +18,15 @@ def test_get_normal_options(): def test_get_wrong_options(): with pytest.raises(InstallingPackageError): - options = get_options_from_comments(inspect.currentframe()) # instld: lol kek cheburek, cheburek mek + options = get_options_from_comments_by_frame(inspect.currentframe()) # instld: lol kek cheburek, cheburek mek with pytest.raises(InstallingPackageError): - options = get_options_from_comments(inspect.currentframe()) # instld: lol + options = get_options_from_comments_by_frame(inspect.currentframe()) # instld: lol with pytest.raises(InstallingPackageError): - options = get_options_from_comments(inspect.currentframe()) # instld: + options = get_options_from_comments_by_frame(inspect.currentframe()) # instld: def test_get_empty_options(): - options = get_options_from_comments(inspect.currentframe()) + options = get_options_from_comments_by_frame(inspect.currentframe()) assert isinstance(options, dict) assert len(options) == 0