From feb3b0742574c8d3466769c85b462831ad0ab575 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Tue, 10 Oct 2023 18:21:31 -0700 Subject: [PATCH 1/6] Infer additional entrypoints These new app patterns are accepted: app-*.py app_*.py *-app.py *_app.py --- rsconnect/bundle.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 074427fb..5f182676 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,6 +1422,12 @@ def get_default_entrypoint(directory): if len(python_files) == 1: return python_files[0][:-3] + # 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] + ":app" + logger.warning("Can't determine entrypoint; defaulting to 'app'") return "app" From b33f5bfce969341144647d4b1cbeff4845f352b2 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Tue, 10 Oct 2023 18:49:00 -0700 Subject: [PATCH 2/6] Fail if no entrypoint inferred; add unit tests --- rsconnect/bundle.py | 3 +-- tests/test_bundle.py | 56 +++++++++++++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index 5f182676..b19f4833 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -1428,8 +1428,7 @@ def get_default_entrypoint(directory): # In these cases, the app should be in the "app" attribute return app_files[0][:-3] + ":app" - logger.warning("Can't determine entrypoint; defaulting to 'app'") - return "app" + raise RSConnectException(f"Could not determine default entrypoint file in directory '{directory}'") def validate_entry_point(entry_point, directory): diff --git a/tests/test_bundle.py b/tests/test_bundle.py index d87ff20a..c872eaf0 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -7,6 +7,7 @@ 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 +1082,42 @@ 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"]: + self._entry_point_case(["helper.py", f"{case}.py"], None, case) + + # app patterns; these differ because they come back with ":app" + for case in ["app-example", "app_example", "example-app", "example_app"]: + self._entry_point_case(["helper.py", f"{case}.py"], None, f"{case}:app") + + # 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") From 2b58b94687ee54eaae4482ed32bb90fff0e2f149 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Tue, 10 Oct 2023 18:49:30 -0700 Subject: [PATCH 3/6] Deprecated actions.get_default_entry should delegate to new impl --- rsconnect/actions.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) 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): From 4ed0f0b41d5c7fb6cd818b3ee84d26461c9023ca Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Tue, 10 Oct 2023 19:09:19 -0700 Subject: [PATCH 4/6] Add changelog entry --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From bf61f27317dac7bfa5d5ab2a04c64b49fd73b40a Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Tue, 10 Oct 2023 19:10:09 -0700 Subject: [PATCH 5/6] Remove unused import --- tests/test_bundle.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_bundle.py b/tests/test_bundle.py index c872eaf0..c3a7ce04 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -2,7 +2,6 @@ import json import os import pytest -import shutil import subprocess import sys import tarfile From 02437280239332025e5c5352cfe8cdb57c4f24e4 Mon Sep 17 00:00:00 2001 From: Joe Cheng Date: Wed, 11 Oct 2023 13:20:01 -0700 Subject: [PATCH 6/6] Simplify logic and test, update docstring --- rsconnect/bundle.py | 7 ++++--- tests/test_bundle.py | 6 +----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/rsconnect/bundle.py b/rsconnect/bundle.py index b19f4833..cd0a5093 100644 --- a/rsconnect/bundle.py +++ b/rsconnect/bundle.py @@ -1426,7 +1426,7 @@ def get_default_entrypoint(directory): 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] + ":app" + return app_files[0][:-3] raise RSConnectException(f"Could not determine default entrypoint file in directory '{directory}'") @@ -1435,10 +1435,11 @@ 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 c3a7ce04..663796bf 100644 --- a/tests/test_bundle.py +++ b/tests/test_bundle.py @@ -1082,13 +1082,9 @@ def test_validate_title(self): def test_validate_entry_point(self): # Simple cases - for case in ["app", "application", "main", "api"]: + 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) - # app patterns; these differ because they come back with ":app" - for case in ["app-example", "app_example", "example-app", "example_app"]: - self._entry_point_case(["helper.py", f"{case}.py"], None, f"{case}:app") - # only one Python file means we assume it's the entrypoint self._entry_point_case(["onlysource.py"], None, "onlysource")