Skip to content
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions ntk/__main__.py
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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()
Expand Down
29 changes: 17 additions & 12 deletions ntk/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import glob
import logging
import os
import time
import sass

from watchfiles import awatch, Change
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Comment thread
alexphelps marked this conversation as resolved.

return True

def _pull_templates(self, template_names):
templates = []
Expand Down Expand Up @@ -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}')
Expand Down Expand Up @@ -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.')
Comment thread
alexphelps marked this conversation as resolved.

@parser_config()
def watch(self, parser):
Expand Down
16 changes: 14 additions & 2 deletions ntk/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Comment thread
alexphelps marked this conversation as resolved.

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
Expand Down
10 changes: 10 additions & 0 deletions ntk/decorator.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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:
Comment thread
alexphelps marked this conversation as resolved.
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()
Expand Down
14 changes: 14 additions & 0 deletions ntk/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""
45 changes: 37 additions & 8 deletions ntk/gateway.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
alexphelps marked this conversation as resolved.
# 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:
Expand All @@ -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)
Comment thread
alexphelps marked this conversation as resolved.
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):
Expand Down
39 changes: 39 additions & 0 deletions tests/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading