diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index a3297c9..dfaec89 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -11,9 +11,9 @@ jobs: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/docs/conf.py b/docs/conf.py index 629b695..7144fdf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,7 +47,7 @@ # General information about the project. project = u'Firenado Framework' -copyright = u'2015-2023, Flavio Garcia' +copyright = u'2015-2024, Flavio Garcia' author = u'Flavio Garcia' # The version info for the project you're documenting, acts as replacement for @@ -57,7 +57,7 @@ # The short X.Y version. version = '0.9' # The full version, including alpha/beta/rc tags. -release = '0.9.3' +release = '0.9.4' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/releases/v0.9.3.rst b/docs/releases/v0.9.3.rst index ade524b..8ec1037 100644 --- a/docs/releases/v0.9.3.rst +++ b/docs/releases/v0.9.3.rst @@ -1,10 +1,10 @@ -What's new in Firenado 0.9.1 +What's new in Firenado 0.9.3 ============================ Nov 25, 2023 ------------ -We are pleased to announce the release of Firenado 0.9.1. +We are pleased to announce the release of Firenado 0.9.3. This release updates pexpect to 4.9.0 and removes the monkey patch from the launcher. diff --git a/docs/releases/v0.9.4.rst b/docs/releases/v0.9.4.rst new file mode 100644 index 0000000..7640bd4 --- /dev/null +++ b/docs/releases/v0.9.4.rst @@ -0,0 +1,17 @@ +What's new in Firenado 0.9.4 +============================ + +Feb 23, 2024 +------------ + +We are pleased to announce the release of Firenado 0.9.4. + +This release adds proper testers to the Firenado framework. + +Here are the highlights: + +Features +~~~~~~~~ + + * Create a TornadoLoader test case `#444 `_ + * Create a ProcessLoader test case `#445 `_ diff --git a/firenado/__init__.py b/firenado/__init__.py index 2aae57d..bb30429 100644 --- a/firenado/__init__.py +++ b/firenado/__init__.py @@ -1,6 +1,6 @@ # -*- coding: UTF-8 -*- # -# Copyright 2015-2023 Flavio Garcia +# Copyright 2015-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -18,7 +18,7 @@ """The Firenado Framework""" __author__ = "Flavio Garcia " -__version__ = (0, 9, 3) +__version__ = (0, 9, 4) __licence__ = "Apache License V2.0" diff --git a/firenado/launcher.py b/firenado/launcher.py index 80d0ce6..c008c7b 100644 --- a/firenado/launcher.py +++ b/firenado/launcher.py @@ -1,4 +1,4 @@ -# Copyright 2015-2023 Flavio Garcia +# Copyright 2015-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -56,13 +56,16 @@ def __init__(self, **settings): os.chdir(self.dir) if self.app is not None or self.dir is not None: reload(firenado.conf) + self.configure_logging() + def configure_logging(self, **kargs): + format = kargs.get("format", firenado.conf.log['format']) + level = kargs.get("level", firenado.conf.log['level']) # Set logging basic configurations for handler in logging.root.handlers[:]: # clearing loggers, solution from: https://bit.ly/2yTchyx logging.root.removeHandler(handler) - logging.basicConfig(level=firenado.conf.log['level'], - format=firenado.conf.log['format']) + logging.basicConfig(level=level, format=format) def load(self): raise NotImplementedError() @@ -152,16 +155,15 @@ def is_alive(self): class TornadoLauncher(FirenadoLauncher): def __init__(self, **settings): - super(TornadoLauncher, self).__init__(**settings) + super().__init__(**settings) self.http_server = None self.application = None self.MAX_WAIT_SECONDS_BEFORE_SHUTDOWN = firenado.conf.app[ 'wait_before_shutdown'] - if self.addresses is None or self.addresses == ['']: - if firenado.conf.app['addresses']: - self.addresses = firenado.conf.app['addresses'] - else: - self.addresses = firenado.conf.app['default_addresses'] + self.addresses = firenado.conf.app['default_addresses'] + if ((self.addresses is None or self.addresses == ['']) and + firenado.conf.app['addresses']): + self.addresses = firenado.conf.app['addresses'] if self.port is None: self.port = firenado.conf.app['port'] @@ -181,13 +183,13 @@ def launch(self): if os.name == "posix": signal.signal(signal.SIGTSTP, self.sig_handler) self.http_server = tornado.httpserver.HTTPServer(self.application) - if firenado.conf.app['xheaders'] is not None and type( - firenado.conf.app['xheaders']) == bool: + if firenado.conf.app['xheaders'] is not None and isinstance( + firenado.conf.app['xheaders'], bool): logger.debug("Setting http server xheaders as %s.", firenado.conf.app['xheaders']) self.http_server.xheaders = firenado.conf.app['xheaders'] - if firenado.conf.app['xheaders'] is not None and type( - firenado.conf.app['xheaders']) != bool: + if firenado.conf.app['xheaders'] is not None and isinstance( + firenado.conf.app['xheaders'], bool): logger.warning("The xheaders defined in the application section" "must be bool instead of %s. Ignoring the " "configuration item.", diff --git a/firenado/schedule.py b/firenado/schedule.py index ab4a798..d613b2c 100644 --- a/firenado/schedule.py +++ b/firenado/schedule.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2015-2023 Flavio Garcia +# Copyright 2015-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -42,8 +40,8 @@ def next_from_cron(cron: str) -> datetime: - """ Return a datatetime object with the next execution based on the informed - cron string and the current time. + """ Return a datatetime object with the next execution based on the + informed cron string and the current time. :param str cron: The cron string :return datetime: A datetime object with the next execution @@ -121,7 +119,7 @@ def remove_job(self, job_id): job = self.get_job(job_id) if job is None: return None - del(self._jobs[job_id]) + del self._jobs[job_id] return job.id def run(self): @@ -135,8 +133,8 @@ def run(self): def _manage_jobs(self): logger.debug("Scheduler [id: %s, name: %s] managing jobs.", self.id, self.name) - logger.debug("Scheduler [id: %s, name: %s] stopping periodic callback." - , self.id, self.name) + logger.debug("Scheduler [id: %s, name: %s] stopping periodic " + "callback.", self.id, self.name) self._periodic_callback.stop() for job in self.jobs: if not job.already_scheduled: @@ -154,8 +152,8 @@ def _manage_jobs(self): logger.debug("Scheduler [id: %s, name: %s] ending of managing jobs.", self.id, self.name) - logger.debug("Scheduler [id: %s, name: %s] starting periodic callback." - , self.id, self.name) + logger.debug("Scheduler [id: %s, name: %s] starting periodic " + "callback.", self.id, self.name) self._periodic_callback.start() diff --git a/firenado/security.py b/firenado/security.py index cb7024c..acf0826 100644 --- a/firenado/security.py +++ b/firenado/security.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2015-2023 Flavio Garcia +# Copyright 2015-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -122,8 +120,9 @@ def default_class_authentication(self: tornadoweb.TornadoHandler): import firenado.conf if "login" in firenado.conf.app: warnings.warn("The \"login\" configuration in the application %s is" - "depreciated. Please replace the configuration app.login" - "to app.security.auth instead.", DeprecationWarning, 2) + "depreciated. Please replace the configuration " + "app.login to app.security.auth instead.", + DeprecationWarning, 2) login_urls = self.get_rooted_path( firenado.conf.app['login']['urls']['default'] ) diff --git a/firenado/testing.py b/firenado/testing.py index be98667..56c5797 100644 --- a/firenado/testing.py +++ b/firenado/testing.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2015-2023 Flavio Garcia +# Copyright 2015-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,14 +13,9 @@ # limitations under the License. import asyncio -from behave.api.async_step import async_run_until_complete -from firenado.launcher import ProcessLauncher +from firenado.launcher import ProcessLauncher, TornadoLauncher from tornado.testing import (bind_unused_port, AsyncTestCase, AsyncHTTPTestCase) -from tornado.httpclient import HTTPResponse -from tornado import gen, ioloop -from typing import Any -import warnings def get_event_loop(): @@ -35,93 +28,55 @@ def get_event_loop(): return loop if loop else asyncio.new_event_loop() -class ProcessLauncherTestCase(AsyncHTTPTestCase): +class TornadoAsyncTestCase(AsyncTestCase): @property - def launcher(self): + def launcher(self) -> ProcessLauncher: return self.__launcher def get_launcher(self) -> ProcessLauncher: """Should be overridden by subclasses to return a - `firenado.launcher.ProcessLauncher`. + `firenado.launcher.TornadoLauncher`. """ raise NotImplementedError() - def get_http_port(self) -> int: - """Returns the port used by the server. - - A new port is chosen for each test. - """ + def http_port(self) -> int: return self.__port - @async_run_until_complete(should_close=False, loop=get_event_loop()) - async def setUp(self) -> None: - self.should_close_asyncio_loop = False - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - sock, port = bind_unused_port() - sock.close() - self.__port = port - self.__launcher = self.get_launcher() - self.__launcher.port = self.__port - self.__launcher.load() - self.http_client = self.get_http_client() - self.io_loop = ioloop.IOLoop.current() - await self.__launcher.launch() - await gen.sleep(1) - - def get_url(self, path: str) -> str: - """Returns an absolute url for the given path on the test server.""" - return "%s://127.0.0.1:%s%s" % (self.get_protocol(), - self.get_http_port(), path) - - async def fetch( - self, path: str, raise_error: bool = False, **kwargs: Any - ) -> HTTPResponse: - """Convenience method to synchronously fetch a URL. - - The given path will be appended to the local server's host and - port. Any additional keyword arguments will be passed directly to - `.AsyncHTTPClient.fetch` (and so could be used to pass - ``method="POST"``, ``body="..."``, etc). - - If the path begins with http:// or https://, it will be treated as a - full URL and will be fetched as-is. - - If ``raise_error`` is ``True``, a `tornado.httpclient.HTTPError` will - be raised if the response code is not 200. This is the same behavior - as the ``raise_error`` argument to `.AsyncHTTPClient.fetch`, but - the default is ``False`` here (it's ``True`` in `.AsyncHTTPClient`) - because tests often need to deal with non-200 response codes. - - .. versionchanged:: 5.0 - Added support for absolute URLs. - - .. versionchanged:: 5.1 - - Added the ``raise_error`` argument. - - .. deprecated:: 5.1 - - This method currently turns any exception into an - `.HTTPResponse` with status code 599. In Tornado 6.0, - errors other than `tornado.httpclient.HTTPError` will be - passed through, and ``raise_error=False`` will only - suppress errors that would be raised due to non-200 - response codes. + def setUp(self) -> None: + import logging + super().setUp() + sock, port = bind_unused_port() + sock.close() + self.__port = port + self.__launcher = self.get_launcher() + self.__launcher.configure_logging(level=logging.ERROR) + self.__launcher.port = self.__port + self.__launcher.load() + asyncio.run(self.__launcher.launch()) + + def tearDown(self) -> None: + self.__launcher.shutdown() + super().tearDown() + +class TornadoAsyncHTTPTestCase(AsyncHTTPTestCase): + + def get_launcher(self) -> TornadoLauncher: + """Should be overridden by subclasses to return a + `firenado.launcher.TornadoLauncher`. """ - if path.lower().startswith(("http://", "https://")): - url = path - else: - url = self.get_url(path) + raise NotImplementedError() - return await self.http_client.fetch(url, raise_error=raise_error, - **kwargs) + def get_log_level(self): + return None - def tearDown(self) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - self.http_client.close() - AsyncTestCase.tearDown(self) - self.__launcher.shutdown() + def get_app(self): + import logging + launcher = self.get_launcher() + launcher.load() + if self.get_log_level() is not None: + launcher.configure_logging(level=self.get_log_level()) + else: + launcher.configure_logging(level=logging.WARN) + return launcher.application diff --git a/requirements/basic.txt b/requirements/basic.txt index e0d4490..ea60f0e 100644 --- a/requirements/basic.txt +++ b/requirements/basic.txt @@ -1,3 +1,3 @@ -cartola>=0.17 +cartola>=0.18 taskio==0.0.6 -tornado==6.3.3 +tornado==6.4 diff --git a/requirements/redis.txt b/requirements/redis.txt index 7dc2f52..f4a0aea 100644 --- a/requirements/redis.txt +++ b/requirements/redis.txt @@ -1,2 +1,2 @@ redis>=5.0.1 -hiredis>=2.2.3 +hiredis>=2.3.2 diff --git a/requirements/tests.txt b/requirements/tests.txt index 38404e0..cfe0222 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,3 +1,3 @@ behave==1.2.6 bandit>=1.7.0 -pymysql==1.0.2 +pymysql==1.1.0 diff --git a/tests/features/steps/launcher.py b/tests/features/steps/launcher.py index 4d6146d..05df181 100644 --- a/tests/features/steps/launcher.py +++ b/tests/features/steps/launcher.py @@ -43,7 +43,7 @@ async def step_application_running_correctly_at_port(context, port): print("Error: %s" % e) context.tester.assertTrue(False) else: - context.tester.assertEqual(b"IndexHandler output", response.body) + context.tester.assertEqual(b"Get output", response.body) context.tester.assertTrue(context.launcher.is_alive()) diff --git a/tests/fixtures/launcherapp/conf/firenado.yml b/tests/fixtures/launcherapp/conf/firenado.yml index 26c3cb6..4adfaff 100644 --- a/tests/fixtures/launcherapp/conf/firenado.yml +++ b/tests/fixtures/launcherapp/conf/firenado.yml @@ -21,7 +21,7 @@ components: # enabled: true log: - level: DEBUG + level: INFO # Session types could be: # file or redis. diff --git a/tests/fixtures/launcherapp/handlers.py b/tests/fixtures/launcherapp/handlers.py index 0d6ffc4..787606c 100644 --- a/tests/fixtures/launcherapp/handlers.py +++ b/tests/fixtures/launcherapp/handlers.py @@ -4,4 +4,7 @@ class IndexHandler(tornadoweb.TornadoHandler): def get(self): - self.write("IndexHandler output") + self.write("Get output") + + def post(self): + self.write("Post output") diff --git a/tests/fixtures/securityapp/conf/firenado.yml b/tests/fixtures/securityapp/conf/firenado.yml index a0cafbc..0bb891f 100644 --- a/tests/fixtures/securityapp/conf/firenado.yml +++ b/tests/fixtures/securityapp/conf/firenado.yml @@ -17,15 +17,15 @@ components: # enabled: true log: - level: DEBUG + level: ERROR # Session types could be: # file or redis. session: - type: redis - enabled: false + type: file + enabled: true # Redis session handler configuration - data: - source: session + # data: + # source: session # File session handler related configuration - # path: /tmp + path: tmp diff --git a/tests/loader_test.py b/tests/loader_test.py new file mode 100644 index 0000000..9c43701 --- /dev/null +++ b/tests/loader_test.py @@ -0,0 +1,66 @@ +# Copyright 2015-2024 Flavio Garcia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from tests import chdir_fixture_app, PROJECT_ROOT +from firenado.launcher import ProcessLauncher +from tornado.httpclient import AsyncHTTPClient +from tornado.testing import bind_unused_port, gen_test, AsyncTestCase + + +class ProcessLauncherTestCase(AsyncTestCase): + + def setUp(self) -> None: + import logging + super().setUp() + sock, port = bind_unused_port() + sock.close() + self.__port = port + self.__launcher = self.get_launcher() + self.__launcher.configure_logging(level=logging.ERROR) + self.__launcher.port = self.__port + self.__launcher.load() + asyncio.run(self.__launcher.launch()) + + def tearDown(self) -> None: + self.__launcher.shutdown() + super().tearDown() + + def get_launcher(self): + application_dir = chdir_fixture_app("launcherapp") + return ProcessLauncher( + dir=application_dir, path=PROJECT_ROOT) + + @gen_test + async def test_get(self): + http_client = AsyncHTTPClient() + try: + response = await http_client.fetch( + f"http://localhost:{self.__port}/") + except Exception as e: + raise e + self.assertEqual(response.body, b"Get output") + + @gen_test + async def test_post(self): + http_client = AsyncHTTPClient() + try: + response = await http_client.fetch( + f"http://localhost:{self.__port}/", + body="", + method="POST" + ) + except Exception as e: + raise e + self.assertEqual(response.body, b"Post output") diff --git a/tests/runtests.py b/tests/runtests.py index 6e170ee..46e04bb 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -16,8 +16,8 @@ import unittest from tests import (components_test, conf_test, config_test, data_test, - security_test, service_test, session_test, sqlalchemy_test, - tornadoweb_test) + loader_test, security_test, service_test, session_test, + sqlalchemy_test, testing_test, tornadoweb_test) from tests.util import url_util_test @@ -28,10 +28,12 @@ def suite(): alltests.addTests(testLoader.loadTestsFromModule(conf_test)) alltests.addTests(testLoader.loadTestsFromModule(config_test)) alltests.addTests(testLoader.loadTestsFromModule(data_test)) + alltests.addTests(testLoader.loadTestsFromModule(loader_test)) alltests.addTests(testLoader.loadTestsFromModule(security_test)) alltests.addTests(testLoader.loadTestsFromModule(service_test)) alltests.addTests(testLoader.loadTestsFromModule(session_test)) alltests.addTests(testLoader.loadTestsFromModule(sqlalchemy_test)) + alltests.addTests(testLoader.loadTestsFromModule(testing_test)) alltests.addTests(testLoader.loadTestsFromModule(tornadoweb_test)) alltests.addTests(testLoader.loadTestsFromModule(url_util_test)) return alltests diff --git a/tests/security_test.py b/tests/security_test.py index 27ff88e..477cb2a 100644 --- a/tests/security_test.py +++ b/tests/security_test.py @@ -1,6 +1,4 @@ -# -*- coding: UTF-8 -*- -# -# Copyright 2015-2023 Flavio Garcia +# Copyright 2015-2024 Flavio Garcia # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -14,13 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio from tests import chdir_fixture_app, PROJECT_ROOT from firenado import security, testing -from firenado.launcher import ProcessLauncher -import sys +from firenado.launcher import TornadoLauncher import unittest -from behave.api.async_step import async_run_until_complete class MockApplication: @@ -48,6 +43,7 @@ def __init__(self): class MockHandler: """ Mock the handler being decorated by the security functions. """ + def __init__(self): self.status = 200 self.response = None @@ -93,16 +89,20 @@ def test_only_xhr(self): "request only.") -class CurrentSecurityTestCase(testing.ProcessLauncherTestCase): +class CurrentSecurityTestCase(testing.TornadoAsyncHTTPTestCase): - def get_launcher(self) -> ProcessLauncher: + def get_launcher(self): application_dir = chdir_fixture_app("securityapp") - return ProcessLauncher( - dir=application_dir, path=PROJECT_ROOT, logfile=sys.stderr) - - # @async_run_until_complete(loop=testing.get_event_loop()) - # async def test_auth_decorated_class(self): - # response = await self.fetch('/authenticated') - # await asyncio.sleep(1) - # print(response.code) - # self.assertEqual(response.body, b'Authenticated') + return TornadoLauncher( + dir=application_dir, path=PROJECT_ROOT) + + def test_root(self): + response = self.fetch("/") + # print(response.code) + self.assertEqual(response.body, b"IndexHandler output") + + # TODO: finish authentication tests + # def test_auth_decorated_class(self): + # response = self.fetch("/authenticated") + # # print(response.code) + # self.assertEqual(response.body, b"Authenticated") diff --git a/tests/testing_test.py b/tests/testing_test.py new file mode 100644 index 0000000..f307ce7 --- /dev/null +++ b/tests/testing_test.py @@ -0,0 +1,68 @@ +# Copyright 2015-2024 Flavio Garcia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from tests import chdir_fixture_app, PROJECT_ROOT +from firenado.testing import TornadoAsyncHTTPTestCase, TornadoAsyncTestCase +from firenado.launcher import ProcessLauncher, TornadoLauncher +from tornado.httpclient import AsyncHTTPClient +from tornado.testing import gen_test + + +class AsyncTestCase(TornadoAsyncTestCase): + + def get_launcher(self): + application_dir = chdir_fixture_app("launcherapp") + return ProcessLauncher( + dir=application_dir, path=PROJECT_ROOT) + + @gen_test + async def test_get(self): + http_client = AsyncHTTPClient() + try: + response = await http_client.fetch( + f"http://localhost:{self.http_port()}/") + except Exception as e: + raise e + self.assertEqual(response.body, b"Get output") + + @gen_test + async def test_post(self): + http_client = AsyncHTTPClient() + try: + response = await http_client.fetch( + f"http://localhost:{self.http_port()}/", + body="", + method="POST" + ) + except Exception as e: + raise e + self.assertEqual(response.body, b"Post output") + + +class AsyncHTTPTestCase(TornadoAsyncHTTPTestCase): + + def get_launcher(self): + application_dir = chdir_fixture_app("launcherapp") + return TornadoLauncher( + dir=application_dir, path=PROJECT_ROOT) + + def test_get(self): + response = self.fetch("/") + # print(response.code) + self.assertEqual(response.body, b"Get output") + + def test_post(self): + response = self.fetch("/", body="", method="POST") + # print(response.code) + self.assertEqual(response.body, b"Post output")