Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Infer entrypoints from additional filename patterns #492

Merged
merged 6 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 2 additions & 15 deletions rsconnect/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down
18 changes: 14 additions & 4 deletions rsconnect/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down
53 changes: 33 additions & 20 deletions tests/test_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading