Skip to content

Commit

Permalink
Merge pull request #492 from rstudio/new-app-entrypoints
Browse files Browse the repository at this point in the history
Infer entrypoints from additional filename patterns
  • Loading branch information
jcheng5 authored Oct 11, 2023
2 parents f4def0c + 0243728 commit a324989
Show file tree
Hide file tree
Showing 4 changed files with 55 additions and 39 deletions.
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

0 comments on commit a324989

Please sign in to comment.