Skip to content

Commit

Permalink
feat: prevent Writer Framework version mismatch local/cloud
Browse files Browse the repository at this point in the history
* feat: warn user about consistency version when running the app
  • Loading branch information
FabienArcellier committed Nov 11, 2024
1 parent 9fca105 commit 3bd8645
Show file tree
Hide file tree
Showing 14 changed files with 1,798 additions and 87 deletions.
10 changes: 5 additions & 5 deletions src/writer/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import dateutil.parser
import pytz
import requests
from click import ClickException
from gitignore_parser import parse_gitignore

from writer import wf_project
Expand Down Expand Up @@ -49,13 +50,14 @@ def deploy(path, api_key, env, verbose, force):
if not os.path.isdir(abs_path):
raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer cloud deploy my_app")

if wf_project.use_poetry(abs_path):
ignore_poetry_version_check = int(os.getenv("IGNORE_POETRY_VERSION_CHECK", '0')) == 1
if wf_project.use_poetry(abs_path) and ignore_poetry_version_check is False:
poetry_locked_version = wf_project.poetry_locked_version(abs_path)
if poetry_locked_version is None:
raise click.ClickException("The application does not have a poetry.lock file. Please run `poetry install` to generate it.")

writer_version = wf_project.writer_version(abs_path)
if poetry_locked_version != writer_version:
if wf_project.compare_semantic_version(poetry_locked_version, writer_version) == -1:
raise click.ClickException(f"locked version in poetry.lock does not match the project version use in .wf, pin a new version `poetry add writer@^{writer_version}")

env = _validate_env_vars(env)
Expand All @@ -68,9 +70,7 @@ def deploy(path, api_key, env, verbose, force):
else:
on_error_print_and_raise(e.response, verbose=verbose)
except Exception as e:
print(e)
print("Error deploying app")
sys.exit(1)
raise ClickException(f"Error deploying app - {e}")
finally:
tar.close()

Expand Down
31 changes: 30 additions & 1 deletion src/writer/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from pydantic import ValidationError
from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState

from writer import VERSION, abstract
from writer import VERSION, abstract, wf_project
from writer.app_runner import AppRunner
from writer.ss_types import (
AppProcessServerResponse,
Expand Down Expand Up @@ -86,6 +86,7 @@ def get_asgi_app(
if serve_mode not in ["run", "edit"]:
raise ValueError("""Invalid mode. Must be either "run" or "edit".""")

_warn_about_version_consistency(serve_mode, user_app_path)
_fix_mimetype()
app_runner = AppRunner(user_app_path, serve_mode)

Expand Down Expand Up @@ -632,6 +633,34 @@ def wf_root_static_assets() -> List[pathlib.Path]:
return all_static_assets


def _warn_about_version_consistency(mode: ServeMode, app_path: str) -> None:
"""
Checks that the version of the writer package used to run the app is equivalent
to or newer than the version used during development.
If it is not the case, a warning message is displayed.
"""
logger = logging.getLogger("writer")
dev_version = wf_project.writer_version(app_path)
if dev_version is None:
return

run_version = wf_project.parse_semantic_version(VERSION)
if wf_project.compare_semantic_version(run_version, dev_version) == -1:
if mode == "run":
logger.warning(f"The version {run_version}, used to run the app, is older than the version {dev_version} used during development.")

if wf_project.use_pyproject(app_path):
logger.warning("You should update the version of writer in the pyproject.toml.")

if wf_project.use_requirement(app_path):
logger.warning("You should update the version of writer in the requirements.txt.")

if mode == "edit":
logger.warning(f"The version {VERSION}, used to edit the app, is older than the version {dev_version} used in previous development.")

logger.warning("")

def _execute_server_setup_hook(user_app_path: str) -> None:
"""
Runs the server_setup.py module if present in the application directory.
Expand Down
18 changes: 16 additions & 2 deletions src/writer/ss_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dataclasses
from typing import Any, Dict, List, Optional, Protocol, Tuple, Union

from pydantic import BaseModel
Expand Down Expand Up @@ -213,5 +214,18 @@ class ComponentDefinition(TypedDict):
class WorkflowExecutionLog(BaseModel):
summary: List[Dict]


SemanticVersion = Tuple[int, int, int, Optional[int]] # major, minor, fix, rc
@dataclasses.dataclass
class SemanticVersion:
major: int
minor: int
fix: int
rc: Optional[int]

def __str__(self):
version = f"{self.major}.{self.minor}.{self.fix}"
if self.rc is not None:
version += f"rc{self.rc}"
return version

def version_tuple(self):
return (self.major, self.minor, self.fix, self.rc)
157 changes: 92 additions & 65 deletions src/writer/wf_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,38 @@ def create_default_workflows_root(app_path: str) -> None:
logger.warning('project format has changed and has been migrated with success. components-workflows_root.jsonl has been added.')


def parse_semantic_version(version: str) -> SemanticVersion:
"""
Parses a version string into a semantic version tuple (major, minor, fix, rc)
>>> parse_semantic_version('1.0.0')
>>> parse_semantic_version('1.0.0rc1')
"""
major, minor, fix_part = version.split('.')
if 'rc' in fix_part:
fix, rc = fix_part.split('rc')
else:
fix, rc = fix_part, None

semantic_version: SemanticVersion
if rc is not None:
semantic_version = SemanticVersion(
major=int(major),
minor=int(minor),
fix=int(fix),
rc=int(rc)
)
else:
semantic_version = SemanticVersion(
major=int(major),
minor=int(minor),
fix=int(fix),
rc=None
)

return semantic_version


def compare_semantic_version(version1: SemanticVersion, version2: SemanticVersion) -> int:
"""
Compares two versions that support semantic version (major, minor, fix, rc).
Expand All @@ -147,81 +179,56 @@ def compare_semantic_version(version1: SemanticVersion, version2: SemanticVersio
* 0 when version1 == version2
* -1 if version1 < version2
>>> compare_semantic_version((1, 0, 0, None), (1, 0, 0, 1)) # 1
>>> compare_semantic_version((1, 0, 0, 1), (1, 0, 0, 4)) # -1
>>> compare_semantic_version((1, 0, 0, 1), (1, 0, 0, 1)) # 0
>>> compare_semantic_version(SemanticVersion(1, 0, 0, None), SemanticVersion(1, 0, 0, 1)) # 1
>>> compare_semantic_version(SemanticVersion(1, 0, 0, 1), SemanticVersion(1, 0, 0, 4)) # -1
>>> compare_semantic_version(SemanticVersion(1, 0, 0, 1), SemanticVersion(1, 0, 0, 1)) # 0
"""
if version1 == version2:
return 0

if version1[:3] > version2[:3]:
v1 = version1.version_tuple()
v2 = version2.version_tuple()
if v1[:3] > v2[:3]:
return 1

if version1[:3] < version2[:3]:
if v1[:3] < v2[:3]:
return -1

if version1[3] is None and version2[3] is not None:
if v1[3] is None and v2[3] is not None:
return 1

if version2[3] is None and version1[3] is not None:
if v2[3] is None and v1[3] is not None:
return -1

return 0


def use_poetry(app_path: str) -> bool:
"""
Checks if the application uses poetry. Checks if the pyproject.tml file and if the build system is that of poetry.
:param app_path: path of the application
:return: True if the application uses poetry
"""
pyproject_path = os.path.join(app_path, 'pyproject.toml')
has_pyproject = os.path.isfile(pyproject_path)
if not has_pyproject:
return False
if v1[3] > v2[3]:
return 1

with io.open(pyproject_path, 'r') as f:
pyproject = toml.load(f)
build_system = pyproject.get('build-system', {}).get('requires', None)
if build_system == 'poetry.core.masonry.api':
return True
if v1[3] < v2[3]:
return -1

return False
return 0


def writer_version(app_path: str) -> typing.Optional[SemanticVersion]:
"""
Retrieves the writer version used during development
>>> v = writer_version('app/hello')
The version is returned as a tuple (major, minor, fix, rc) or None if not found
* 1.0.0 -> (1, 0, 0, None)
* 1.0.0rc1 -> (1, 0, 0, 1)
:param app_path: path of the application
:return: the version of writer used during development or None
"""
meta, _ = read_files(app_path)
writer_version = meta.get('writer_version', None)
if writer_version:
return _parse_semantic_version(writer_version)
return parse_semantic_version(writer_version)

return None


def poetry_locked_version(app_path: str) -> typing.Optional[SemanticVersion]:
"""
Retrieves the version fixed in the poetry.lock file for Writer
If the application does not have a poetry.lock file, the function returns None
The version is returned as a tuple (major, minor, fix, rc) or None if not found
* 1.0.0 -> (1, 0, 0, None)
* 1.0.0rc1 -> (1, 0, 0, 1)
:param app_path: absolute path of the application
:return: the version of writer freezed in the poetry.lock file
"""
Expand All @@ -235,13 +242,55 @@ def poetry_locked_version(app_path: str) -> typing.Optional[SemanticVersion]:
for package in poetry_lock['package']:
if package['name'] == 'writer':
locked_version = package['version']
return locked_version
return parse_semantic_version(locked_version)

return None


def use_poetry(app_path: str) -> bool:
"""
Checks if the application uses poetry. Checks if the pyproject.tml file and if the build system is that of poetry.
>>> if wf_project.use_poetry('app/hello'):
>>> print('The dependency manager is poetry')
:param app_path: path of the application
:return: True if the application uses poetry
"""
pyproject_path = os.path.join(app_path, 'pyproject.toml')
has_pyproject = os.path.isfile(pyproject_path)
if not has_pyproject:
return False

with io.open(pyproject_path, 'r') as f:
pyproject = toml.load(f)
build_system = pyproject.get('build-system', {}).get('build-backend', None)
if build_system == 'poetry.core.masonry.api':
return True

return False


def use_pyproject(app_path: str) -> bool:
"""
Checks if the application uses pyproject.toml.
>>> if wf_project.use_pyproject('app/hello'):
>>> print('The dependency are managed with pyproject.toml')
"""
pyproject_path = os.path.join(app_path, 'pyproject.toml')
return os.path.isfile(pyproject_path)


def use_requirement(app_path: str):
return None
"""
Checks if the application uses requirements.txt.
>>> if wf_project.use_requirement('app/hello'):
>>> print('The dependency manager is pip')
"""
requirements_path = os.path.join(app_path, 'requirements.txt')
return os.path.isfile(requirements_path)


def _expected_component_fileinfos(components: dict[str, ComponentDefinition]) -> List[Tuple[str, str]]:
Expand Down Expand Up @@ -340,25 +389,3 @@ def _hierarchical_position(components, c):
return list(reversed(p))

return sorted(components.values(), key=lambda c: _hierarchical_position(components, c))


def _parse_semantic_version(version: str) -> SemanticVersion:
"""
Parses a version string into a semantic version tuple (major, minor, fix, rc)
>>> _parse_semantic_version('1.0.0') # (1, 0, 0, None)
>>> _parse_semantic_version('1.0.0rc1') # (1, 0, 0, 1)
"""
major, minor, fix_part = version.split('.')
if 'rc' in fix_part:
fix, rc = fix_part.split('rc')
else:
fix, rc = fix_part, None

semantic_version: SemanticVersion
if rc is not None:
semantic_version = int(major), int(minor), int(fix), int(rc)
else:
semantic_version = int(major), int(minor), int(fix), None

return semantic_version
1 change: 1 addition & 0 deletions tests/backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
test_multiapp_dir = Path(__file__).resolve().parent / 'testmultiapp'
test_basicauth_dir = Path(__file__).resolve().parent / 'testbasicauth'
testobsoleteapp = Path(__file__).resolve().parent / 'testobsoleteapp'
testmorerecentapp_dir = Path(__file__).resolve().parent / 'testmorerecentapp'
Loading

0 comments on commit 3bd8645

Please sign in to comment.