diff --git a/README.md b/README.md index 326a01d..0719b5f 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ development: > [!WARNING] > Keep the API key out of source control. Do not commit `config.yml` to git if it contains the key. +> [!TIP] +> For CI or scripts, set the API key with the `NTK_APIKEY` environment variable instead of a flag or `config.yml`. The key is resolved in this order: `NTK_APIKEY`, then `--apikey`, then `config.yml`. A key from `NTK_APIKEY` is never written to `config.yml`. + > [!NOTE] > `config.yml` supports multiple environments. Commands use the `development` entry by default; pass `-e` / `--env` to target another environment (for example `ntk push --env=production`). The `[development]` prefix in command output is the active environment. diff --git a/ntk/__main__.py b/ntk/__main__.py index ec73b1d..4125f46 100644 --- a/ntk/__main__.py +++ b/ntk/__main__.py @@ -1,8 +1,11 @@ #!/usr/bin/env python import logging +import sys +from importlib.metadata import version, PackageNotFoundError from requests.exceptions import HTTPError +from ntk.exceptions import NTKError from ntk.ntk_parser import Parser logging.basicConfig( @@ -12,13 +15,26 @@ ) +def theme_kit_version(): + try: + return version('next-theme-kit') + except PackageNotFoundError: + return 'unknown' + + def main(): + logging.info(f'NEXT Theme Kit version {theme_kit_version()}') parser = Parser().create_parser() args = parser.parse_args() try: args.func(args) except AttributeError: print('Use ntk -h or --help to see available commands') + except NTKError as e: + # print new line for support error on process progress bar + print() + logging.error(e) + sys.exit(1) except (TypeError, HTTPError) as e: # print new line for support error on process progress bar print() diff --git a/ntk/command.py b/ntk/command.py index 991f388..581e9ce 100644 --- a/ntk/command.py +++ b/ntk/command.py @@ -2,7 +2,6 @@ import glob import logging import os -import time import sass from watchfiles import awatch, Change @@ -12,6 +11,7 @@ SASS_EXTENSIONS, ) from ntk.decorator import parser_config +from ntk.exceptions import NTKError, NTKRequestError from ntk.gateway import Gateway from ntk.utils import get_template_name, progress_bar @@ -49,12 +49,17 @@ def _handle_files_change(self, changes): if not pathfile.endswith(valid_extensions): continue template_name = get_template_name(pathfile) - if event_type in [Change.added, Change.modified]: - logging.info(f'[{self.config.env}] {event_type.name.title()} {template_name}') - self._push_templates([template_name], compile_sass=True) - elif event_type == Change.deleted: - logging.info(f'[{self.config.env}] {event_type.name.title()} {template_name}') - self._delete_templates([template_name]) + try: + if event_type in [Change.added, Change.modified]: + logging.info(f'[{self.config.env}] {event_type.name.title()} {template_name}') + self._push_templates([template_name], compile_sass=True) + elif event_type == Change.deleted: + logging.info(f'[{self.config.env}] {event_type.name.title()} {template_name}') + self._delete_templates([template_name]) + except NTKRequestError as error: + # Keep watching on a transient failure (network/throttle). Permanent errors + # (bad key, missing theme) propagate and stop the watcher. + logging.error(f'[{self.config.env}] {error}') def _push_templates(self, template_names, compile_sass=False): template_names = self._get_accept_files(template_names) @@ -85,9 +90,10 @@ def _push_templates(self, template_names, compile_sass=False): response = self.gateway.create_or_update_template( theme_id=self.config.theme_id, template_name=relative_pathfile, content=content, files=files) - time.sleep(0.07) if not response.ok: - return + return False + + return True def _pull_templates(self, template_names): templates = [] @@ -128,8 +134,6 @@ def _pull_templates(self, template_names): template_file.write(template.get('content')) template_file.close() - time.sleep(0.08) - def _delete_templates(self, template_names): template_count = len(template_names) logging.info(f'[{self.config.env}] Connecting to {self.config.store}') @@ -186,7 +190,8 @@ def checkout(self, parser): @parser_config() def push(self, parser): - self._push_templates(parser.filenames or []) + if not self._push_templates(parser.filenames or []): + raise NTKError(f'[{self.config.env}] Push failed, see the error above.') @parser_config() def watch(self, parser): diff --git a/ntk/conf.py b/ntk/conf.py index ae874f4..b6ff190 100644 --- a/ntk/conf.py +++ b/ntk/conf.py @@ -58,6 +58,9 @@ class Config(object): theme_id = None sass_output_style = None + # True when apikey came from the NTK_APIKEY env var, so it is never written to config.yml. + apikey_from_env = False + env = 'development' apikey_required = True @@ -72,7 +75,11 @@ def __init__(self, **kwargs): def parser_config(self, parser, write_file=False): self.env = parser.env self.read_config() - if getattr(parser, 'apikey', None): + # API key precedence: NTK_APIKEY, then --apikey, then config.yml (loaded above). + if os.environ.get('NTK_APIKEY'): + self.apikey = os.environ['NTK_APIKEY'] + self.apikey_from_env = True + elif getattr(parser, 'apikey', None): self.apikey = parser.apikey if getattr(parser, 'theme_id', None): @@ -124,14 +131,19 @@ def read_config(self, update=True): def write_config(self): configs = self.read_config(update=False) + # Never persist an env-supplied key; keep whatever key was already on disk. + apikey = (configs.get(self.env) or {}).get('apikey') if self.apikey_from_env else self.apikey + new_config = { - 'apikey': self.apikey, 'store': self.store, 'theme_id': self.theme_id, 'sass': { 'output_style': self.sass_output_style or 'nested' # default sass output style is nested } } + # Only persist the key when there is one; avoids writing `apikey: None` on a first env-only run. + if apikey: + new_config['apikey'] = apikey # If the config has been changed, then the config will be saved to config.yml. if configs.get(self.env) != new_config: configs[self.env] = new_config diff --git a/ntk/decorator.py b/ntk/decorator.py index 711960a..a814e38 100644 --- a/ntk/decorator.py +++ b/ntk/decorator.py @@ -1,6 +1,8 @@ import functools import logging +from ntk.exceptions import NTKAuthError, NTKNotFoundError + logging.basicConfig( format='%(asctime)s %(levelname)s %(message)s', level=logging.INFO, @@ -35,6 +37,14 @@ def _decorator(func): @functools.wraps(func) def _wrapper(self, *func_args, **func_kwargs): response = func(self, *func_args, **func_kwargs) + + if response.status_code == 401: + raise NTKAuthError(f'Invalid API key for {self.store}.') + + if response.status_code == 404: + raise NTKNotFoundError( + f'Not found: {response.url} — check the store URL and theme id.') + error_default = f'{func.__name__.capitalize().replace("_", " ")} of {self.store} failed.' error_msg = "" content_type = response.headers.get('content-type', '').lower() diff --git a/ntk/exceptions.py b/ntk/exceptions.py new file mode 100644 index 0000000..d3125de --- /dev/null +++ b/ntk/exceptions.py @@ -0,0 +1,14 @@ +class NTKError(Exception): + """Base class for ntk errors surfaced to the CLI entry point.""" + + +class NTKAuthError(NTKError): + """Raised on a 401 response (invalid or missing API key).""" + + +class NTKNotFoundError(NTKError): + """Raised on a 404 response (store, theme, or template not found).""" + + +class NTKRequestError(NTKError): + """Raised when a request keeps failing (connection error or throttling) after retries.""" diff --git a/ntk/gateway.py b/ntk/gateway.py index 2edfdd3..0b29588 100644 --- a/ntk/gateway.py +++ b/ntk/gateway.py @@ -1,7 +1,25 @@ +import logging +import time import requests from urllib.parse import urljoin from ntk.decorator import check_error +from ntk.exceptions import NTKRequestError + +MAX_RETRIES = 3 +# The store API rate limit is 4 requests/second (250ms apart). Wait 400ms before every +# request to stay under the limit so requests are never throttled. +REQUEST_INTERVAL_SECONDS = 0.4 +# Give up on a single request after 30 seconds so a dropped connection fails instead of hanging. +REQUEST_TIMEOUT = 30 + +# Transport-level failures that are worth retrying rather than surfacing as a raw stack trace. +TRANSIENT_EXCEPTIONS = ( + requests.exceptions.ConnectionError, + requests.exceptions.Timeout, + requests.exceptions.ChunkedEncodingError, + requests.exceptions.SSLError, +) class Gateway: @@ -10,14 +28,25 @@ def __init__(self, store, apikey): self.apikey = apikey def _request(self, request_type, url, apikey=None, payload={}, files={}): - headers = {} - if apikey: - headers = {'Authorization': f'Bearer {apikey}'} - - response = requests.request(request_type, url, headers=headers, data=payload, files=files) - if response.status_code == 429 and "throttled" in response.content.decode(): - return self._request(request_type, url, apikey, payload, files) - return response + headers = {'Authorization': f'Bearer {apikey}'} if apikey else {} + + reason = 'connection failed' + for attempt in range(MAX_RETRIES): + # Space every request (and retry) to stay under the store rate limit. + time.sleep(REQUEST_INTERVAL_SECONDS) + try: + response = requests.request( + request_type, url, headers=headers, data=payload, files=files, timeout=REQUEST_TIMEOUT) + except TRANSIENT_EXCEPTIONS as error: + reason = 'timed out' if isinstance(error, requests.exceptions.Timeout) else 'connection failed' + else: + if not (response.status_code == 429 and "throttled" in response.content.decode()): + return response + reason = 'throttled' + + logging.warning(f'Request to {self.store} {reason} (attempt {attempt + 1}/{MAX_RETRIES}).') + + raise NTKRequestError(f'Request to {self.store} failed after {MAX_RETRIES} attempts ({reason}).') @check_error(error_format='Missing Themes in {store}') def get_themes(self): diff --git a/tests/test_command.py b/tests/test_command.py index b6e5766..13e1a14 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -367,6 +367,18 @@ def test_push_command_ignores_invalid_file_extensions_when_filenames_provided( self.assertIn('templates/index.html', str(upload_calls[0])) self.assertNotIn('.tmp', str(upload_calls[0])) + @patch("ntk.command.Command._get_accept_files", autospec=True) + def test_push_command_raises_when_an_upload_fails(self, mock_get_accept_files): + """A failed upload must make ntk push raise (exit non-zero), not finish silently.""" + from ntk.exceptions import NTKError + mock_get_accept_files.return_value = [f'{os.getcwd()}/layout/base.html'] + self.mock_gateway.return_value.create_or_update_template.return_value.ok = False + self.command.config.parser_config(self.parser) + self.parser.filenames = None + with patch("builtins.open", self.mock_file): + with self.assertRaises(NTKError): + self.command.push(self.parser) + @patch("ntk.command.glob.glob", autospec=True) def test_get_accept_files_with_no_filenames_returns_only_glob_matched_files( self, mock_glob @@ -487,6 +499,33 @@ def test_watch_command_with_create_image_file_should_call_gateway_with_correct_a ) self.assertIn(expected_call_added, self.mock_gateway.mock_calls) + @patch("ntk.command.Command._push_templates", autospec=True) + def test_watch_logs_and_continues_on_transient_error(self, mock_push_templates): + """A transient NTKRequestError during watch should be logged, not kill the watcher.""" + from ntk.exceptions import NTKRequestError + mock_push_templates.side_effect = NTKRequestError('Request to http://development.com failed.') + self.command.config.parser_config(self.parser) + changes = [ + (Change.modified, './templates/index.html'), + ] + # Should not raise — the transient error is caught inside _handle_files_change. + with self.assertLogs(level='ERROR') as log: + self.command._handle_files_change(changes) + self.assertTrue( + any('Request to http://development.com failed.' in line for line in log.output)) + + @patch("ntk.command.Command._push_templates", autospec=True) + def test_watch_stops_on_permanent_error(self, mock_push_templates): + """A permanent NTKAuthError should propagate and stop the watcher, not be swallowed.""" + from ntk.exceptions import NTKAuthError + mock_push_templates.side_effect = NTKAuthError('Invalid API key for http://development.com.') + self.command.config.parser_config(self.parser) + changes = [ + (Change.modified, './templates/index.html'), + ] + with self.assertRaises(NTKAuthError): + self.command._handle_files_change(changes) + @patch("ntk.command.Command._get_accept_files", autospec=True) @patch("ntk.command.Command._compile_sass", autospec=True) def test_watch_command_with_sass_directory_should_call_compile_sass( diff --git a/tests/test_config.py b/tests/test_config.py index d51fd4c..01fc957 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,13 @@ from ntk.conf import Config +def _parser(**overrides): + defaults = {'env': 'development', 'apikey': None, 'store': 'http://simple.com', + 'theme_id': 1, 'sass_output_style': None} + defaults.update(overrides) + return MagicMock(**defaults) + + class TestConfig(unittest.TestCase): def setUp(self): config = { @@ -154,20 +161,110 @@ def test_parser_config_should_set_config_config_correctly(self): } parser = MagicMock(**config) - with patch("ntk.conf.Config.write_config") as mock_write_config: - self.config.parser_config(parser=parser) + with patch.dict('os.environ', {}, clear=True): + with patch("ntk.conf.Config.write_config") as mock_write_config: + self.config.parser_config(parser=parser) - self.assertEqual(self.config.apikey, '2b78f637972b1c9d1234') - self.assertEqual(self.config.store, 'http://sandbox.com') - self.assertEqual(self.config.theme_id, 1234) - self.assertEqual(self.config.sass_output_style, 'nested') - mock_write_config.assert_not_called() + self.assertEqual(self.config.apikey, '2b78f637972b1c9d1234') + self.assertEqual(self.config.store, 'http://sandbox.com') + self.assertEqual(self.config.theme_id, 1234) + self.assertEqual(self.config.sass_output_style, 'nested') + mock_write_config.assert_not_called() - with patch("ntk.conf.Config.write_config") as mock_write_config: - self.config.parser_config(parser=parser, write_file=True) + with patch("ntk.conf.Config.write_config") as mock_write_config: + self.config.parser_config(parser=parser, write_file=True) self.assertEqual(self.config.apikey, '2b78f637972b1c9d1234') self.assertEqual(self.config.store, 'http://sandbox.com') self.assertEqual(self.config.theme_id, 1234) self.assertEqual(self.config.sass_output_style, 'nested') mock_write_config.assert_called_once() + + ##### + # NTK_APIKEY environment variable + ##### + @patch("ntk.conf.Config.write_config") + @patch("os.path.exists", autospec=True) + def test_parser_config_uses_env_apikey_when_no_cli_flag(self, mock_exists, mock_write): + mock_exists.return_value = False # no config.yml + with patch.dict('os.environ', {'NTK_APIKEY': 'env-key'}): + self.config.parser_config(parser=_parser(apikey=None)) + + self.assertEqual(self.config.apikey, 'env-key') + self.assertTrue(self.config.apikey_from_env) + + @patch("ntk.conf.Config.write_config") + @patch("os.path.exists", autospec=True) + def test_parser_config_env_apikey_beats_cli_flag(self, mock_exists, mock_write): + mock_exists.return_value = False + with patch.dict('os.environ', {'NTK_APIKEY': 'env-key'}): + self.config.parser_config(parser=_parser(apikey='cli-key')) + + self.assertEqual(self.config.apikey, 'env-key') + self.assertTrue(self.config.apikey_from_env) + + @patch("ntk.conf.Config.write_config") + @patch("os.path.exists", autospec=True) + def test_parser_config_cli_apikey_used_when_no_env(self, mock_exists, mock_write): + mock_exists.return_value = False + with patch.dict('os.environ', {}, clear=True): + self.config.parser_config(parser=_parser(apikey='cli-key')) + + self.assertEqual(self.config.apikey, 'cli-key') + self.assertFalse(self.config.apikey_from_env) + + @patch("ntk.conf.Config.write_config") + @patch("yaml.load", autospec=True) + @patch("os.path.exists", autospec=True) + def test_parser_config_env_apikey_beats_config_file(self, mock_exists, mock_load, mock_write): + mock_exists.return_value = True + mock_load.return_value = {'development': {'apikey': 'file-key', 'store': 'http://simple.com', 'theme_id': 1}} + with patch('builtins.open', mock_open(read_data='yaml data')): + with patch.dict('os.environ', {'NTK_APIKEY': 'env-key'}): + self.config.parser_config(parser=_parser(apikey=None)) + + self.assertEqual(self.config.apikey, 'env-key') + self.assertTrue(self.config.apikey_from_env) + + @patch("yaml.dump", autospec=True) + @patch("yaml.load", autospec=True) + @patch("os.path.exists", autospec=True) + def test_write_config_does_not_persist_env_apikey(self, mock_exists, mock_load, mock_dump): + mock_exists.return_value = True + mock_load.return_value = { + 'development': { + 'apikey': 'file-key', 'store': 'http://simple.com', 'theme_id': 1, + 'sass': {'output_style': 'nested'} + } + } + self.config.apikey = 'env-key' + self.config.apikey_from_env = True + self.config.store = 'http://simple.com' + self.config.theme_id = 2 # differ from disk so a write is triggered + self.config.sass_output_style = 'nested' + + with patch('builtins.open', mock_open()): + self.config.write_config() + + dumped = mock_dump.call_args[0][0] + self.assertEqual(dumped['development']['apikey'], 'file-key') + self.assertEqual(dumped['development']['theme_id'], 2) + + @patch("yaml.dump", autospec=True) + @patch("yaml.load", autospec=True) + @patch("os.path.exists", autospec=True) + def test_write_config_omits_apikey_on_first_env_only_run(self, mock_exists, mock_load, mock_dump): + mock_exists.return_value = False # no config.yml yet + mock_load.return_value = {} + + self.config.apikey = 'env-key' + self.config.apikey_from_env = True + self.config.store = 'http://simple.com' + self.config.theme_id = 1 + self.config.sass_output_style = 'nested' + + with patch('builtins.open', mock_open()): + self.config.write_config() + + dumped = mock_dump.call_args[0][0] + self.assertNotIn('apikey', dumped['development']) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 0165d24..edf8a86 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -1,7 +1,10 @@ import unittest from unittest.mock import call, MagicMock, patch -from ntk.gateway import Gateway +import requests + +from ntk.exceptions import NTKAuthError, NTKNotFoundError, NTKRequestError +from ntk.gateway import Gateway, MAX_RETRIES, REQUEST_INTERVAL_SECONDS, REQUEST_TIMEOUT class TestGateway(unittest.TestCase): @@ -38,12 +41,13 @@ def test_request(self, mock_request): headers={'Authorization': 'Bearer apikey'}, data={ 'name': 'assets/base.html', 'content': '{% load i18n %}\n\n
My home page
'}, - files=files) + files=files, timeout=REQUEST_TIMEOUT) ] assert mock_request.mock_calls == expected_calls + @patch('ntk.gateway.time.sleep', autospec=True) @patch('ntk.gateway.requests.request', autospec=True) - def test_request_with_rate_limit_should_retry(self, mock_request): + def test_request_with_rate_limit_should_retry(self, mock_request, mock_sleep): mock_response_429 = MagicMock() mock_response_429.status_code = 429 mock_response_429.content.decode.return_value = "throttled" @@ -72,16 +76,88 @@ def test_request_with_rate_limit_should_retry(self, mock_request): headers={'Authorization': 'Bearer apikey'}, data={ 'name': 'assets/base.html', 'content': '{% load i18n %}\n\n
My home page
' - }, files=files), + }, files=files, timeout=REQUEST_TIMEOUT), call( 'POST', 'http://simple.com/api/admin/themes/5/templates/', headers={'Authorization': 'Bearer apikey'}, data={ 'name': 'assets/base.html', 'content': '{% load i18n %}\n\n
My home page
' - }, files=files) + }, files=files, timeout=REQUEST_TIMEOUT) ] assert mock_request.mock_calls == expected_calls + @patch('ntk.gateway.time.sleep', autospec=True) + @patch('ntk.gateway.requests.request', autospec=True) + def test_request_raises_after_persistent_throttling(self, mock_request, mock_sleep): + mock_response_429 = MagicMock() + mock_response_429.status_code = 429 + mock_response_429.content.decode.return_value = "throttled" + + mock_request.return_value = mock_response_429 + + with self.assertRaises(NTKRequestError): + self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + + assert mock_request.call_count == MAX_RETRIES + + @patch('ntk.gateway.time.sleep', autospec=True) + @patch('ntk.gateway.requests.request', autospec=True) + def test_request_retries_transient_error_then_succeeds(self, mock_request, mock_sleep): + mock_response_200 = MagicMock() + mock_response_200.status_code = 200 + + mock_request.side_effect = [requests.exceptions.ConnectionError(), mock_response_200] + + response = self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + + assert mock_request.call_count == 2 + assert response is mock_response_200 + + @patch('ntk.gateway.time.sleep', autospec=True) + @patch('ntk.gateway.requests.request', autospec=True) + def test_request_raises_after_persistent_transient_error(self, mock_request, mock_sleep): + # A Timeout is retried, clearly logged, then surfaced as a clean error — not a raw stack trace. + mock_request.side_effect = requests.exceptions.Timeout() + + with self.assertLogs(level='WARNING') as log: + with self.assertRaises(NTKRequestError): + self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + + assert mock_request.call_count == MAX_RETRIES + self.assertTrue(any('timed out' in line for line in log.output)) + + @patch('ntk.gateway.time.sleep', autospec=True) + @patch('ntk.gateway.requests.request', autospec=True) + def test_request_waits_between_requests_to_respect_rate_limit(self, mock_request, mock_sleep): + mock_request.return_value.status_code = 200 + + self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + + # Every request is spaced by the fixed rate-limit interval (no exponential backoff). + mock_sleep.assert_called_once_with(REQUEST_INTERVAL_SECONDS) + + ##### + # 401 / 404 handling (check_error decorator) + ##### + @patch('ntk.gateway.requests.request', autospec=True) + def test_request_401_raises_auth_error(self, mock_request): + mock_request.return_value.status_code = 401 + + with self.assertRaises(NTKAuthError) as error: + self.gateway.get_themes() + + self.assertEqual(str(error.exception), 'Invalid API key for http://simple.com.') + + @patch('ntk.gateway.requests.request', autospec=True) + def test_request_404_raises_not_found_error_with_url(self, mock_request): + mock_request.return_value.status_code = 404 + mock_request.return_value.url = 'http://simple.com/api/admin/themes/6/templates/' + + with self.assertRaises(NTKNotFoundError) as error: + self.gateway.get_templates(theme_id=6) + + self.assertIn('http://simple.com/api/admin/themes/6/templates/', str(error.exception)) + ##### # get_themes ##### @@ -104,7 +180,7 @@ def test_get_themes(self, mock_request): self.gateway.get_themes() expected_call = call('GET', 'http://simple.com/api/admin/themes/', - headers={'Authorization': 'Bearer apikey'}, data={}, files={}) + headers={'Authorization': 'Bearer apikey'}, data={}, files={}, timeout=REQUEST_TIMEOUT) self.assertIn(expected_call, mock_request.mock_calls) #### @@ -131,7 +207,8 @@ def test_create_theme(self, mock_request): self.gateway.create_theme(name="Test Init Theme") expected_call = call('POST', 'http://simple.com/api/admin/themes/', - headers={'Authorization': 'Bearer apikey'}, data=payload, files={}) + headers={'Authorization': 'Bearer apikey'}, data=payload, files={}, + timeout=REQUEST_TIMEOUT) self.assertIn(expected_call, mock_request.mock_calls) ##### @@ -158,7 +235,7 @@ def test_get_templates(self, mock_request): self.gateway.get_templates(theme_id=6) expected_call = call('GET', 'http://simple.com/api/admin/themes/6/templates/', - headers={'Authorization': 'Bearer apikey'}, data={}, files={}) + headers={'Authorization': 'Bearer apikey'}, data={}, files={}, timeout=REQUEST_TIMEOUT) self.assertIn(expected_call, mock_request.mock_calls) ##### @@ -186,7 +263,7 @@ def test_get_template(self, mock_request): self.gateway.get_template(theme_id=6, template_name=template_name) expected_call = call('GET', f'http://simple.com/api/admin/themes/6/templates/?name={template_name}', - headers={'Authorization': 'Bearer apikey'}, data={}, files={}) + headers={'Authorization': 'Bearer apikey'}, data={}, files={}, timeout=REQUEST_TIMEOUT) self.assertIn(expected_call, mock_request.mock_calls) ##### @@ -220,7 +297,8 @@ def test_create_or_update_template(self, mock_request): theme_id=6, template_name=payload['name'], content=payload['content'], files=files) expected_call = call('POST', 'http://simple.com/api/admin/themes/6/templates/', - headers={'Authorization': 'Bearer apikey'}, data=payload, files=files) + headers={'Authorization': 'Bearer apikey'}, data=payload, files=files, + timeout=REQUEST_TIMEOUT) self.assertIn(expected_call, mock_request.mock_calls) ##### @@ -248,5 +326,5 @@ def test_delete_template(self, mock_request): self.gateway.delete_template(theme_id=6, template_name='asset/custom.css') expected_call = call('DELETE', 'http://simple.com/api/admin/themes/6/templates/?name=asset/custom.css', - headers={'Authorization': 'Bearer apikey'}, data={}, files={}) + headers={'Authorization': 'Bearer apikey'}, data={}, files={}, timeout=REQUEST_TIMEOUT) self.assertIn(expected_call, mock_request.mock_calls) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..79f9fbb --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,52 @@ +import unittest +from unittest.mock import MagicMock, patch + +from importlib.metadata import PackageNotFoundError + +from ntk.__main__ import main, theme_kit_version +from ntk.exceptions import NTKNotFoundError + + +class TestMain(unittest.TestCase): + @patch('ntk.__main__.Parser', autospec=True) + def test_main_exits_1_on_ntk_error(self, mock_parser): + args = MagicMock() + args.func.side_effect = NTKNotFoundError( + 'Not found: http://simple.com/api/admin/themes/6/templates/ — check the store URL and theme id.') + mock_parser.return_value.create_parser.return_value.parse_args.return_value = args + + with self.assertRaises(SystemExit) as exit_context: + with self.assertLogs(level='ERROR') as log: + main() + + self.assertEqual(exit_context.exception.code, 1) + self.assertTrue(any('Not found' in line for line in log.output)) + + @patch('ntk.__main__.Parser', autospec=True) + def test_main_prints_help_hint_on_attribute_error(self, mock_parser): + args = MagicMock() + args.func.side_effect = AttributeError() + mock_parser.return_value.create_parser.return_value.parse_args.return_value = args + + # No command supplied — should print the help hint and not raise. + main() + + @patch('ntk.__main__.Parser', autospec=True) + def test_main_logs_theme_kit_version(self, mock_parser): + args = MagicMock() + args.func.return_value = None + mock_parser.return_value.create_parser.return_value.parse_args.return_value = args + + with self.assertLogs(level='INFO') as log: + main() + + self.assertTrue(any('NEXT Theme Kit version' in line for line in log.output)) + + @patch('ntk.__main__.version', autospec=True) + def test_theme_kit_version_returns_unknown_when_not_installed(self, mock_version): + mock_version.side_effect = PackageNotFoundError() + self.assertEqual(theme_kit_version(), 'unknown') + + +if __name__ == '__main__': + unittest.main()