diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d3ee39..8ce5225c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 hosts that should not be accessed via proxy server. It's a comma-separated list of host or domain suffixes. For example, specifying `example.com` will bypass the proxy for example.com, host.example.com, etc. +- If an entrypoint is not specified with `--entrypoint`, rsconnect-python will try + harder than before to choose an entrypoint file. In addition to the previously + recognized filename patterns, the file patterns `app-*.py`, `app_*.py`, `*-app.py`, + and `*_app.py` are now considered. However, if the directory contains more than + one file matching these new patterns, you must provide rsconnect-python with an + explicit `--entrypoint` argument. ## [1.20.0] - 2023-09-11 diff --git a/rsconnect/actions.py b/rsconnect/actions.py index 4af40e60..d0449403 100644 --- a/rsconnect/actions.py +++ b/rsconnect/actions.py @@ -16,6 +16,7 @@ from os.path import abspath, basename, dirname, exists, isdir, join, relpath, splitext from .exception import RSConnectException from . import api +from . import bundle from .bundle import ( _warn_if_environment_directory, _warn_if_no_requirements_file, @@ -415,21 +416,7 @@ def validate_manifest_file(file_or_directory): def get_default_entrypoint(directory): warn("This method has been moved and will be deprecated.", DeprecationWarning, stacklevel=2) - candidates = ["app", "application", "main", "api"] - files = set(os.listdir(directory)) - - for candidate in candidates: - filename = candidate + ".py" - if filename in files: - return candidate - - # if only one python source file, use it - python_files = list(filter(lambda s: s.endswith(".py"), files)) - if len(python_files) == 1: - return python_files[0][:-3] - - logger.warning("Can't determine entrypoint; defaulting to 'app'") - return "app" + return bundle.get_default_entrypoint(directory) def validate_entry_point(entry_point, directory): diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 074427fb..cd0a5093 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -1404,6 +1404,10 @@ def validate_manifest_file(file_or_directory): return file_or_directory +re_app_prefix = re.compile(r"^app[-_].+\.py$") +re_app_suffix = re.compile(r".+[-_]app\.py$") + + def get_default_entrypoint(directory): candidates = ["app", "application", "main", "api"] files = set(os.listdir(directory)) @@ -1418,18 +1422,24 @@ def get_default_entrypoint(directory): if len(python_files) == 1: return python_files[0][:-3] - logger.warning("Can't determine entrypoint; defaulting to 'app'") - return "app" + # try app-*.py, app_*.py, *-app.py, *_app.py + app_files = list(filter(lambda s: re_app_prefix.match(s) or re_app_suffix.match(s), python_files)) + if len(app_files) == 1: + # In these cases, the app should be in the "app" attribute + return app_files[0][:-3] + + raise RSConnectException(f"Could not determine default entrypoint file in directory '{directory}'") def validate_entry_point(entry_point, directory): """ Validates the entry point specified by the user, expanding as necessary. If the user specifies nothing, a module of "app" is assumed. If the user specifies a - module only, the object is assumed to be the same name as the module. + module only, at runtime the following object names will be tried in order: `app`, + `application`, `create_app`, and `make_app`. :param entry_point: the entry point as specified by the user. - :return: the fully expanded and validated entry point and the module file name.. + :return: An entry point, in the form of "module" or "module:app". """ if not entry_point: entry_point = get_default_entrypoint(directory) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index d87ff20a..663796bf 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -2,11 +2,11 @@ import json import os import pytest -import shutil import subprocess import sys import tarfile import tempfile +from pathlib import Path from os.path import dirname, join, basename, abspath from unittest import mock, TestCase @@ -1081,25 +1081,38 @@ def test_validate_title(self): _validate_title("1" * 1024) def test_validate_entry_point(self): - directory = tempfile.mkdtemp() - - try: - self.assertEqual(validate_entry_point(None, directory), "app") - self.assertEqual(validate_entry_point("app", directory), "app") - self.assertEqual(validate_entry_point("app:app", directory), "app:app") - - with self.assertRaises(RSConnectException): - validate_entry_point("x:y:z", directory) - - with open(join(directory, "onlysource.py"), "w") as f: - f.close() - self.assertEqual(validate_entry_point(None, directory), "onlysource") - - with open(join(directory, "main.py"), "w") as f: - f.close() - self.assertEqual(validate_entry_point(None, directory), "main") - finally: - shutil.rmtree(directory) + # Simple cases + for case in ["app", "application", "main", "api", "app-example", "app_example", "example-app", "example_app"]: + self._entry_point_case(["helper.py", f"{case}.py"], None, case) + + # only one Python file means we assume it's the entrypoint + self._entry_point_case(["onlysource.py"], None, "onlysource") + + # Explicit entrypoint specifiers, no need to infer + self._entry_point_case(["helper.py", "app.py"], "app", "app") + self._entry_point_case(["helper.py", "app.py"], "app:app", "app:app") + self._entry_point_case(["helper.py", "app.py"], "foo:bar", "foo:bar") + + def test_validate_entry_point_failure(self): + # Invalid entrypoint specifier + self._entry_point_case(["app.py"], "x:y:z", False) + # Nothing relevant found + self._entry_point_case(["one.py", "two.py"], "x:y:z", False) + # Too many app-*.py files + self._entry_point_case(["app-one.py", "app-two.py"], "x:y:z", False) + + def _entry_point_case(self, files, entry_point, expected): + with tempfile.TemporaryDirectory() as directory: + dir = Path(directory) + + for file in files: + (dir / file).touch() + + if expected is False: + with self.assertRaises(RSConnectException): + validate_entry_point(entry_point, directory) + else: + self.assertEqual(validate_entry_point(entry_point, directory), expected) def test_default_title(self): self.assertEqual(_default_title("testing.txt"), "testing")