From 5de4d617095df7bd151be4fbe287c9de7f79a36d Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Fri, 24 Jul 2026 16:14:03 +0700 Subject: [PATCH 01/10] Revert "improve readme" This reverts commit 30f31758da3191ffedac78f2ebc4f4aa822a11d7. --- README.md | 246 ++++++++++++++++++++++++------------------------------ 1 file changed, 111 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 326a01d..372ec29 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,16 @@ # Next Commerce Theme Kit -[Theme Kit](https://github.com/NextCommerceCo/theme-kit) is a command line tool for developers to build and maintain storefront themes programmatically, allowing theme developers to: - -- Work on theme templates and assets using their local code editor or favorite IDE. -- Use git version control to work on a theme collectively with many theme collaborators. -- Use a pipeline to manage deployments of theme updates. +Theme Kit is a cross-platform command line tool to build and maintain storefront themes with [Sass Processing](#sass-processing) support on the Next Commerce platform. ## Installation -Theme Kit is a Python package available on [PyPi](https://pypi.org/project/next-theme-kit/). +Theme Kit is a python package available on [PyPi](https://pypi.org/project/next-theme-kit/) If you already have `python` and `pip`, install with the following command: -```bash -pip install next-theme-kit +``` +python -m pip install next-theme-kit ``` #### Mac OSX Requirements @@ -26,185 +22,165 @@ See how to install `python` and `pip` with [HomeBrew](https://docs.brew.sh/Homeb #### Windows Requirements -* **Option 1 (Recommended)** — Windows 10 and above feature WSL (Windows Subsystem for Linux) which provides a native Linux environment, see how to [Install WSL with Ubuntu](https://docs.microsoft.com/en-us/windows/wsl/install). Once you have installed WSL, follow the [best practice guides to configure and use with VS Code](https://docs.microsoft.com/en-us/windows/wsl/setup/environment) and then follow the `pip` instructions above to install Theme Kit. -* **Option 2** — Installing `python` in Windows natively can be done through the [Windows App Store](https://apps.microsoft.com/store/detail/python-39/9P7QFQMJRFP7?hl=en-us&gl=us). Recommend using [Windows Powershell](https://apps.microsoft.com/store/detail/powershell/9MZ1SNWT0N5D?hl=en-us&gl=us). This route is a little more tricky and some knowledge on how to manage Python in Windows will be required. - -> [!TIP] -> **Use Python Virtual Environments** — For Mac, Windows, and Linux, it's a best practice to use a Python Virtual Environment to isolate Python packages and dependencies to reduce potential conflicts or errors, [more on creating a Python Virtual Environment](https://www.freecodecamp.org/news/how-to-setup-virtual-environments-in-python/). - -## Setup +* **Option 1 (Recommended)** - Windows 10 and above feature WSL (Windows Subsystem for Linux) which provides a native Linux environment, see how to [Install WSL with Ubuntu](https://docs.microsoft.com/en-us/windows/wsl/install). Once you have installed WSL, follow the [best practice guides to configure and use with VS Code](https://docs.microsoft.com/en-us/windows/wsl/setup/environment) and then follow the `pip` instructions above to install Theme Kit. -Connect `ntk` to a store in three steps. +* **Option 2** - Installing `python` in Windows natively can be done with through the [Windows App Store](https://apps.microsoft.com/store/detail/python-39/9P7QFQMJRFP7?hl=en-us&gl=us). Recommend using [Windows Powershell](https://apps.microsoft.com/store/detail/powershell/9MZ1SNWT0N5D?hl=en-us&gl=us). This route is a little more tricky and some knowledge on how to manage python in windows will be required. -### 1. Create the API Key +> **Use Python Virtual Environments** - For Mac, Windows, and Linux, it's a best practice to use a Python Virtual Environment to isolate python packages and dependecies to reduce potential conflicts or errors, [more on creating a Python Virutal Environment](https://www.freecodecamp.org/news/how-to-setup-virtual-environments-in-python/). -Store authentication uses [OAuth 2.0](https://auth0.com/intro-to-iam/what-is-oauth-2/) and requires creating a store OAuth App with the `themes:read` and `themes:write` permissions. -1. In the Storefront admin, go to **Settings > API Access**. -2. Click **Create App**. -3. Give the app a name and assign a user. -4. In the **Permissions** tab, enable `themes:read` and `themes:write`. -5. **Save**. Copy the generated API key — you will need it in the next step. +#### Updating Theme Kit -### 2. Configure Theme Kit - -`ntk` reads its connection settings from two places: command flags (`--apikey`, `--store`, `--theme_id`) and the `config.yml` file in your theme directory. You do not need to create `config.yml` by hand — `ntk checkout` and `ntk init` write it for you, and after that commands run without flags: - -```yaml -development: - apikey: - store: https://{store}.29next.store - theme_id: +Update to the latest version of Theme Kit with the following command: ``` - -> [!WARNING] -> Keep the API key out of source control. Do not commit `config.yml` to git if it contains the key. - -> [!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. - -### 3. Connect to a Theme - -Work from a copy of an existing theme rather than an empty directory — a complete theme is the reference for the required directories, templates, and settings. - -**Work on a theme already on the store** — `ntk checkout` downloads the theme into your current directory and writes `config.yml`: - -```bash -ntk checkout --theme_id= --apikey="" --store="https://{store}.29next.store" +python -m pip install next-theme-kit --upgrade ``` -**Add a new theme to the store** — start from a copy of an existing theme, such as the [Intro Bootstrap](https://github.com/NextCommerceCo/intro-bootstrap) starter theme, then register it as a new theme with `ntk init` and upload the files with `ntk push`: - -```bash -ntk init --name="" --apikey="" --store="https://{store}.29next.store" -ntk push -``` +> :warning: **Important** +> +> As of version 1.0.2, the store authentication uses Oauth and requires creating a store **Oauth App** with `themes:read` and `themes:write` permissions. ## Usage - With the package installed, you can now use the commands inside your theme directory and work on a storefront theme. -| Command | Description | -| -------------- | ----------- | -| `ntk init` | Initialize a new theme | -| `ntk list` | List all available themes | -| `ntk checkout` | Checkout an existing theme | -| `ntk pull` | Download existing theme or theme file | -| `ntk push` | Push current theme state to store | -| `ntk watch` | Watch for local changes and automatically push changes to store | -| `ntk sass` | Process sass to css, see [Sass Processing](#sass-processing) | +**Available Commands** +* `ntk init` - initialize a new theme +* `ntk list` - list all available themes +* `ntk checkout` - checkout an existing theme +* `ntk pull` - download existing theme or theme file +* `ntk push` - push current theme state to store +* `ntk watch` - watch for local changes and automatically push changes to store +* `ntk sass` - process sass to css, see [Sass Processing](#sass-processing) -### Browse Store Themes +**Important** - You must pass the `apikey` and `store` parameters for all commands **if** there is not an existing `config.yml` file in your current directory. -To see what themes exist on the store, run `ntk list` to print the theme ID and name of each, with the active theme marked. +#### Init +Initialize a new theme which will create the theme on a store and create an initial config.yml file -```bash -ntk list ``` +ntk init --name="" --apikey="" --store="" +``` +##### Required flags without config.yml +| Short | Long | Description| +|--- | --- | --- | +| -a | --apikey | API Key used to connect to the store.| +| -s | --store | Full domain of the store. | +| -n | --name | Name of the new theme | -Output looks like: +#### List +List all themes installed on the theme. ``` -[development] Available themes: -[development] [42] Spring Launch -[development] [43] Holiday Promo (Active) +ntk list --apikey="" --store="" ``` +##### Required flags without config.yml +| Short | Long | Description| +|--- | --- | --- | +| -a | --apikey | API Key used to connect to the store.| +| -s | --store | Full domain of the store. | -If you do not have a `config.yml`, also pass `--apikey` and `--store`. - -### Work on an Existing Theme -To start working on a theme that already exists on the store, `ntk checkout` downloads it into your directory and writes `config.yml` with the theme ID. - -```bash -ntk checkout --theme_id= +#### Checkout +Checkout a theme from your store to pull it into your directory. ``` - -`--theme_id` / `-t` is required. If you do not have a `config.yml`, also pass `--apikey` and `--store`: - -```bash -ntk checkout --theme_id= --apikey="" --store="https://{store}.29next.store" +ntk checkout --theme_id= --apikey="" --store="" ``` - -`ntk checkout` differs from `ntk pull` in one way: `checkout` writes `config.yml` so the directory is ready for subsequent `ntk push` / `ntk watch` runs; `pull` downloads the same files without writing `config.yml`. - -### Add a New Theme to the Store - -`ntk init` registers your current directory as a new theme on the store and writes a `config.yml`. It does not download or scaffold any files — run it inside an existing theme codebase, then `ntk push` to upload the files. - -> [!WARNING] -> Building a theme from an empty directory is not advised. Start from a copy of a complete theme — the [Intro Bootstrap](https://github.com/NextCommerceCo/intro-bootstrap) starter theme or an existing theme from your store via [`ntk checkout`](#work-on-an-existing-theme). - -```bash -ntk init --name="" +##### Required flags without config.yml +| Short | Long | Description| +|--- | --- | --- | +| -a | --apikey | API Key used to connect to the store.| +| -s | --store | Full domain of the store. | +| -t | --theme_id | ID of the theme. | + +#### Pull +Pull a theme from your store to into your directory. ``` - -`--name` / `-n` is required. If you do not have a `config.yml` yet, also pass `--apikey` and `--store`: - -```bash -ntk init --name="" --apikey="" --store="https://{store}.29next.store" +ntk pull --theme_id= --apikey="" --store="" ``` +##### Required flags without config.yml +| Short | Long | Description| +|--- | --- | --- | +| -a | --apikey | API Key used to connect to the store.| +| -s | --store | Full domain of the store. | +| -t | --theme_id | ID of the theme. | -On success, `ntk init` logs the new theme ID and name, and persists the theme ID into `config.yml` so subsequent commands can omit `--theme_id`. - -### Sync Files to the Store - -To sync files between your local directory and the store, use `ntk push` to upload and `ntk pull` to download. Both upload or download the whole theme by default, and both accept file paths as positional arguments to limit the operation to specific files. - -> [!NOTE] -> File paths are relative to the theme root. `ntk push` only uploads files inside the theme directories (`assets`, `checkout`, `configs`, `layouts`, `locales`, `partials`, `sass`, `templates`) with valid theme file extensions — a path outside of them is skipped silently, not reported as an error. -| Example | Command | -| ------- | ------- | -| Push a single file | `ntk push templates/index.html` | -| Push a subset of files | `ntk push templates/index.html assets/main.css` | -| Pull a single file | `ntk pull templates/index.html` | -| Pull a subset of files | `ntk pull templates/index.html assets/main.css` | - -### Watch for File Changes +#### Push +Push all theme files from your local directory to the store. +``` +ntk push --theme_id= --apikey="" --store="" +``` +##### Required flags without config.yml +| Short | Long | Description| +|--- | --- | --- | +| -a | --apikey | API Key used to connect to the store.| +| -s | --store | Full domain of the store. | +| -t | --theme_id | ID of the theme. | -`ntk watch` monitors your theme directory and automatically pushes changed files to the store. Use it while you develop — save a file and the change is uploaded moments later. -```bash -ntk watch +#### Watch +Watch for file changes and additions in your local directory and automatically push them to the store. ``` +ntk watch --theme_id= --apikey="" --store="" +``` +##### Required flags without config.yml +| Short | Long | Description| +|--- | --- | --- | +| -a | --apikey | API Key used to connect to the store.| +| -s | --store | Full domain of the store. | +| -t | --theme_id | ID of the theme. | -On start, `ntk watch` logs the store, theme ID, a preview-theme URL, and the directory it is watching. Press `Ctrl + C` to stop. +#### Sass +Process `sass` files to CSS files for inclusion in your storefront. See [Sass Processing](#sass-processing) for more details. -> [!WARNING] -> Deletes sync too — deleting a local file while `ntk watch` is running deletes that file from the theme on the store. +``` +ntk sass +``` +##### Optional flags +| Short | Long | Description| +|--- | --- | --- | +| -sos | --sass_output_style | Options are nested, expanded, compact, or compressed| -> [!NOTE] -> `ntk watch` only uploads files with valid theme extensions. It does not accept file arguments. To scope changes to specific files, run `ntk push` with file paths instead. -### Sass Processing +## Sass Processing Theme kit includes support for Sass processing via [Python Libsass](https://sass.github.io/libsass-python/). Sass processing includes support for variables, imports, nesting, mixins, inheritance, custom functions, and more. -> [!WARNING] -> Sass processing is only supported on local, files in the `sass` directory are uploaded to your store for storage but cannot be edited in the store theme editor. - **How it works** - 1. Put `scss` files in top level `sass` directory. 2. Run `ntk sass` or `ntk watch` to process theme `sass` files. 3. Top level `scss` files will be processed to `css` files in the asset directory with the same name. **Example Theme with Sass Structure** - ``` ├── assets -│ ├── main.css // reference this asset file in templates +│   ├── styles.css // reference this asset file in templates ├── sass -│ ├── _base.scss -│ ├── _variables.scss -│ └── main.scss // processed to assets/main.css +│   ├── _base.scss +│   ├── _variables.scss +│   └── styles.scss // processed to assets/main.css ``` +**Important** - Sass processing is only supported on local, files in the `sass` directory are uploaded to your store for storage but cannot be edited in the store theme editor. + +**Configure Default Output Style** + +Change the default sass output style in `config.yml`, example below. + +``` +development: + apikey: + sass: + output_style: compressed // options: nested, expanded, compact, or compressed + store: + theme_id: +``` + + [codecov-image]: https://codecov.io/gh/29next/theme-kit/branch/master/graph/badge.svg?token=LPUOTZ5MZ5 [codecov-link]: https://codecov.io/gh/29next/theme-kit [pypi-v-image]: https://img.shields.io/pypi/v/next-theme-kit.svg [pypi-v-link]: https://pypi.org/project/next-theme-kit/ [GHAction-image]: https://github.com/NextCommerceCo/theme-kit/actions/workflows/test.yml/badge.svg?branch=master -[GHAction-link]: https://github.com/NextCommerceCo/theme-kit/actions?query=event%3appush+branch%3amaster +[GHAction-link]: https://github.com/NextCommerceCo/theme-kit/actions?query=event%3Apush+branch%3Amaster From 55f4effe0ad8bdaae1caab3955a2a800f8559b0b Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Fri, 24 Jul 2026 17:46:59 +0700 Subject: [PATCH 02/10] improve error handling --- ntk/decorator.py | 10 ++++++++++ ntk/gateway.py | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ntk/decorator.py b/ntk/decorator.py index 711960a..f87a116 100644 --- a/ntk/decorator.py +++ b/ntk/decorator.py @@ -1,5 +1,6 @@ import functools import logging +import sys logging.basicConfig( format='%(asctime)s %(levelname)s %(message)s', @@ -35,6 +36,15 @@ 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: + logging.error(f'Invalid API key for {self.store}.') + sys.exit(1) + + if response.status_code == 404: + logging.error(f'Theme or template not found for {self.store}.') + sys.exit(1) + 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/gateway.py b/ntk/gateway.py index 2edfdd3..bda5149 100644 --- a/ntk/gateway.py +++ b/ntk/gateway.py @@ -14,7 +14,10 @@ def _request(self, request_type, url, apikey=None, payload={}, files={}): if apikey: headers = {'Authorization': f'Bearer {apikey}'} - response = requests.request(request_type, url, headers=headers, data=payload, files=files) + try: + response = requests.request(request_type, url, headers=headers, data=payload, files=files) + except requests.exceptions.ConnectionError: + return self._request(request_type, url, apikey, payload, files) if response.status_code == 429 and "throttled" in response.content.decode(): return self._request(request_type, url, apikey, payload, files) return response From cd3bf7df9f382e7c6cd268a9bf63fb7b07f3454f Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Fri, 24 Jul 2026 17:52:34 +0700 Subject: [PATCH 03/10] revert readme changes --- README.md | 246 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 135 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 372ec29..326a01d 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,20 @@ # Next Commerce Theme Kit -Theme Kit is a cross-platform command line tool to build and maintain storefront themes with [Sass Processing](#sass-processing) support on the Next Commerce platform. +[Theme Kit](https://github.com/NextCommerceCo/theme-kit) is a command line tool for developers to build and maintain storefront themes programmatically, allowing theme developers to: + +- Work on theme templates and assets using their local code editor or favorite IDE. +- Use git version control to work on a theme collectively with many theme collaborators. +- Use a pipeline to manage deployments of theme updates. ## Installation -Theme Kit is a python package available on [PyPi](https://pypi.org/project/next-theme-kit/) +Theme Kit is a Python package available on [PyPi](https://pypi.org/project/next-theme-kit/). If you already have `python` and `pip`, install with the following command: -``` -python -m pip install next-theme-kit +```bash +pip install next-theme-kit ``` #### Mac OSX Requirements @@ -22,165 +26,185 @@ See how to install `python` and `pip` with [HomeBrew](https://docs.brew.sh/Homeb #### Windows Requirements -* **Option 1 (Recommended)** - Windows 10 and above feature WSL (Windows Subsystem for Linux) which provides a native Linux environment, see how to [Install WSL with Ubuntu](https://docs.microsoft.com/en-us/windows/wsl/install). Once you have installed WSL, follow the [best practice guides to configure and use with VS Code](https://docs.microsoft.com/en-us/windows/wsl/setup/environment) and then follow the `pip` instructions above to install Theme Kit. +* **Option 1 (Recommended)** — Windows 10 and above feature WSL (Windows Subsystem for Linux) which provides a native Linux environment, see how to [Install WSL with Ubuntu](https://docs.microsoft.com/en-us/windows/wsl/install). Once you have installed WSL, follow the [best practice guides to configure and use with VS Code](https://docs.microsoft.com/en-us/windows/wsl/setup/environment) and then follow the `pip` instructions above to install Theme Kit. +* **Option 2** — Installing `python` in Windows natively can be done through the [Windows App Store](https://apps.microsoft.com/store/detail/python-39/9P7QFQMJRFP7?hl=en-us&gl=us). Recommend using [Windows Powershell](https://apps.microsoft.com/store/detail/powershell/9MZ1SNWT0N5D?hl=en-us&gl=us). This route is a little more tricky and some knowledge on how to manage Python in Windows will be required. + +> [!TIP] +> **Use Python Virtual Environments** — For Mac, Windows, and Linux, it's a best practice to use a Python Virtual Environment to isolate Python packages and dependencies to reduce potential conflicts or errors, [more on creating a Python Virtual Environment](https://www.freecodecamp.org/news/how-to-setup-virtual-environments-in-python/). + +## Setup -* **Option 2** - Installing `python` in Windows natively can be done with through the [Windows App Store](https://apps.microsoft.com/store/detail/python-39/9P7QFQMJRFP7?hl=en-us&gl=us). Recommend using [Windows Powershell](https://apps.microsoft.com/store/detail/powershell/9MZ1SNWT0N5D?hl=en-us&gl=us). This route is a little more tricky and some knowledge on how to manage python in windows will be required. +Connect `ntk` to a store in three steps. -> **Use Python Virtual Environments** - For Mac, Windows, and Linux, it's a best practice to use a Python Virtual Environment to isolate python packages and dependecies to reduce potential conflicts or errors, [more on creating a Python Virutal Environment](https://www.freecodecamp.org/news/how-to-setup-virtual-environments-in-python/). +### 1. Create the API Key +Store authentication uses [OAuth 2.0](https://auth0.com/intro-to-iam/what-is-oauth-2/) and requires creating a store OAuth App with the `themes:read` and `themes:write` permissions. -#### Updating Theme Kit +1. In the Storefront admin, go to **Settings > API Access**. +2. Click **Create App**. +3. Give the app a name and assign a user. +4. In the **Permissions** tab, enable `themes:read` and `themes:write`. +5. **Save**. Copy the generated API key — you will need it in the next step. -Update to the latest version of Theme Kit with the following command: +### 2. Configure Theme Kit + +`ntk` reads its connection settings from two places: command flags (`--apikey`, `--store`, `--theme_id`) and the `config.yml` file in your theme directory. You do not need to create `config.yml` by hand — `ntk checkout` and `ntk init` write it for you, and after that commands run without flags: + +```yaml +development: + apikey: + store: https://{store}.29next.store + theme_id: ``` -python -m pip install next-theme-kit --upgrade + +> [!WARNING] +> Keep the API key out of source control. Do not commit `config.yml` to git if it contains the key. + +> [!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. + +### 3. Connect to a Theme + +Work from a copy of an existing theme rather than an empty directory — a complete theme is the reference for the required directories, templates, and settings. + +**Work on a theme already on the store** — `ntk checkout` downloads the theme into your current directory and writes `config.yml`: + +```bash +ntk checkout --theme_id= --apikey="" --store="https://{store}.29next.store" ``` -> :warning: **Important** -> -> As of version 1.0.2, the store authentication uses Oauth and requires creating a store **Oauth App** with `themes:read` and `themes:write` permissions. +**Add a new theme to the store** — start from a copy of an existing theme, such as the [Intro Bootstrap](https://github.com/NextCommerceCo/intro-bootstrap) starter theme, then register it as a new theme with `ntk init` and upload the files with `ntk push`: + +```bash +ntk init --name="" --apikey="" --store="https://{store}.29next.store" +ntk push +``` ## Usage + With the package installed, you can now use the commands inside your theme directory and work on a storefront theme. -**Available Commands** -* `ntk init` - initialize a new theme -* `ntk list` - list all available themes -* `ntk checkout` - checkout an existing theme -* `ntk pull` - download existing theme or theme file -* `ntk push` - push current theme state to store -* `ntk watch` - watch for local changes and automatically push changes to store -* `ntk sass` - process sass to css, see [Sass Processing](#sass-processing) +| Command | Description | +| -------------- | ----------- | +| `ntk init` | Initialize a new theme | +| `ntk list` | List all available themes | +| `ntk checkout` | Checkout an existing theme | +| `ntk pull` | Download existing theme or theme file | +| `ntk push` | Push current theme state to store | +| `ntk watch` | Watch for local changes and automatically push changes to store | +| `ntk sass` | Process sass to css, see [Sass Processing](#sass-processing) | -**Important** - You must pass the `apikey` and `store` parameters for all commands **if** there is not an existing `config.yml` file in your current directory. +### Browse Store Themes -#### Init -Initialize a new theme which will create the theme on a store and create an initial config.yml file +To see what themes exist on the store, run `ntk list` to print the theme ID and name of each, with the active theme marked. +```bash +ntk list ``` -ntk init --name="" --apikey="" --store="" -``` -##### Required flags without config.yml -| Short | Long | Description| -|--- | --- | --- | -| -a | --apikey | API Key used to connect to the store.| -| -s | --store | Full domain of the store. | -| -n | --name | Name of the new theme | +Output looks like: -#### List -List all themes installed on the theme. ``` -ntk list --apikey="" --store="" +[development] Available themes: +[development] [42] Spring Launch +[development] [43] Holiday Promo (Active) ``` -##### Required flags without config.yml -| Short | Long | Description| -|--- | --- | --- | -| -a | --apikey | API Key used to connect to the store.| -| -s | --store | Full domain of the store. | +If you do not have a `config.yml`, also pass `--apikey` and `--store`. -#### Checkout -Checkout a theme from your store to pull it into your directory. -``` -ntk checkout --theme_id= --apikey="" --store="" -``` -##### Required flags without config.yml -| Short | Long | Description| -|--- | --- | --- | -| -a | --apikey | API Key used to connect to the store.| -| -s | --store | Full domain of the store. | -| -t | --theme_id | ID of the theme. | - -#### Pull -Pull a theme from your store to into your directory. -``` -ntk pull --theme_id= --apikey="" --store="" -``` -##### Required flags without config.yml -| Short | Long | Description| -|--- | --- | --- | -| -a | --apikey | API Key used to connect to the store.| -| -s | --store | Full domain of the store. | -| -t | --theme_id | ID of the theme. | +### Work on an Existing Theme +To start working on a theme that already exists on the store, `ntk checkout` downloads it into your directory and writes `config.yml` with the theme ID. -#### Push -Push all theme files from your local directory to the store. +```bash +ntk checkout --theme_id= ``` -ntk push --theme_id= --apikey="" --store="" -``` -##### Required flags without config.yml -| Short | Long | Description| -|--- | --- | --- | -| -a | --apikey | API Key used to connect to the store.| -| -s | --store | Full domain of the store. | -| -t | --theme_id | ID of the theme. | +`--theme_id` / `-t` is required. If you do not have a `config.yml`, also pass `--apikey` and `--store`: -#### Watch -Watch for file changes and additions in your local directory and automatically push them to the store. +```bash +ntk checkout --theme_id= --apikey="" --store="https://{store}.29next.store" ``` -ntk watch --theme_id= --apikey="" --store="" + +`ntk checkout` differs from `ntk pull` in one way: `checkout` writes `config.yml` so the directory is ready for subsequent `ntk push` / `ntk watch` runs; `pull` downloads the same files without writing `config.yml`. + +### Add a New Theme to the Store + +`ntk init` registers your current directory as a new theme on the store and writes a `config.yml`. It does not download or scaffold any files — run it inside an existing theme codebase, then `ntk push` to upload the files. + +> [!WARNING] +> Building a theme from an empty directory is not advised. Start from a copy of a complete theme — the [Intro Bootstrap](https://github.com/NextCommerceCo/intro-bootstrap) starter theme or an existing theme from your store via [`ntk checkout`](#work-on-an-existing-theme). + +```bash +ntk init --name="" ``` -##### Required flags without config.yml -| Short | Long | Description| -|--- | --- | --- | -| -a | --apikey | API Key used to connect to the store.| -| -s | --store | Full domain of the store. | -| -t | --theme_id | ID of the theme. | -#### Sass -Process `sass` files to CSS files for inclusion in your storefront. See [Sass Processing](#sass-processing) for more details. +`--name` / `-n` is required. If you do not have a `config.yml` yet, also pass `--apikey` and `--store`: +```bash +ntk init --name="" --apikey="" --store="https://{store}.29next.store" ``` -ntk sass + +On success, `ntk init` logs the new theme ID and name, and persists the theme ID into `config.yml` so subsequent commands can omit `--theme_id`. + +### Sync Files to the Store + +To sync files between your local directory and the store, use `ntk push` to upload and `ntk pull` to download. Both upload or download the whole theme by default, and both accept file paths as positional arguments to limit the operation to specific files. + +> [!NOTE] +> File paths are relative to the theme root. `ntk push` only uploads files inside the theme directories (`assets`, `checkout`, `configs`, `layouts`, `locales`, `partials`, `sass`, `templates`) with valid theme file extensions — a path outside of them is skipped silently, not reported as an error. + +| Example | Command | +| ------- | ------- | +| Push a single file | `ntk push templates/index.html` | +| Push a subset of files | `ntk push templates/index.html assets/main.css` | +| Pull a single file | `ntk pull templates/index.html` | +| Pull a subset of files | `ntk pull templates/index.html assets/main.css` | + +### Watch for File Changes + +`ntk watch` monitors your theme directory and automatically pushes changed files to the store. Use it while you develop — save a file and the change is uploaded moments later. + +```bash +ntk watch ``` -##### Optional flags -| Short | Long | Description| -|--- | --- | --- | -| -sos | --sass_output_style | Options are nested, expanded, compact, or compressed| +On start, `ntk watch` logs the store, theme ID, a preview-theme URL, and the directory it is watching. Press `Ctrl + C` to stop. + +> [!WARNING] +> Deletes sync too — deleting a local file while `ntk watch` is running deletes that file from the theme on the store. + +> [!NOTE] +> `ntk watch` only uploads files with valid theme extensions. It does not accept file arguments. To scope changes to specific files, run `ntk push` with file paths instead. +### Sass Processing -## Sass Processing Theme kit includes support for Sass processing via [Python Libsass](https://sass.github.io/libsass-python/). Sass processing includes support for variables, imports, nesting, mixins, inheritance, custom functions, and more. +> [!WARNING] +> Sass processing is only supported on local, files in the `sass` directory are uploaded to your store for storage but cannot be edited in the store theme editor. + **How it works** + 1. Put `scss` files in top level `sass` directory. 2. Run `ntk sass` or `ntk watch` to process theme `sass` files. 3. Top level `scss` files will be processed to `css` files in the asset directory with the same name. **Example Theme with Sass Structure** + ``` ├── assets -│   ├── styles.css // reference this asset file in templates +│ ├── main.css // reference this asset file in templates ├── sass -│   ├── _base.scss -│   ├── _variables.scss -│   └── styles.scss // processed to assets/main.css +│ ├── _base.scss +│ ├── _variables.scss +│ └── main.scss // processed to assets/main.css ``` -**Important** - Sass processing is only supported on local, files in the `sass` directory are uploaded to your store for storage but cannot be edited in the store theme editor. - -**Configure Default Output Style** - -Change the default sass output style in `config.yml`, example below. - -``` -development: - apikey: - sass: - output_style: compressed // options: nested, expanded, compact, or compressed - store: - theme_id: -``` - - [codecov-image]: https://codecov.io/gh/29next/theme-kit/branch/master/graph/badge.svg?token=LPUOTZ5MZ5 [codecov-link]: https://codecov.io/gh/29next/theme-kit [pypi-v-image]: https://img.shields.io/pypi/v/next-theme-kit.svg [pypi-v-link]: https://pypi.org/project/next-theme-kit/ [GHAction-image]: https://github.com/NextCommerceCo/theme-kit/actions/workflows/test.yml/badge.svg?branch=master -[GHAction-link]: https://github.com/NextCommerceCo/theme-kit/actions?query=event%3Apush+branch%3Amaster +[GHAction-link]: https://github.com/NextCommerceCo/theme-kit/actions?query=event%3appush+branch%3amaster From 1314048296bc97118e6224af2feb1fc10113018a Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Fri, 24 Jul 2026 18:11:49 +0700 Subject: [PATCH 04/10] improve approach --- ntk/__main__.py | 7 +++++ ntk/command.py | 17 +++++++---- ntk/decorator.py | 10 +++---- ntk/exceptions.py | 14 +++++++++ ntk/gateway.py | 35 +++++++++++++++++----- tests/test_command.py | 15 ++++++++++ tests/test_gateway.py | 68 +++++++++++++++++++++++++++++++++++++++++-- tests/test_main.py | 34 ++++++++++++++++++++++ 8 files changed, 180 insertions(+), 20 deletions(-) create mode 100644 ntk/exceptions.py create mode 100644 tests/test_main.py diff --git a/ntk/__main__.py b/ntk/__main__.py index ec73b1d..5da23e2 100644 --- a/ntk/__main__.py +++ b/ntk/__main__.py @@ -1,8 +1,10 @@ #!/usr/bin/env python import logging +import sys from requests.exceptions import HTTPError +from ntk.exceptions import NTKError from ntk.ntk_parser import Parser logging.basicConfig( @@ -19,6 +21,11 @@ def main(): 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..8a79690 100644 --- a/ntk/command.py +++ b/ntk/command.py @@ -12,6 +12,7 @@ SASS_EXTENSIONS, ) from ntk.decorator import parser_config +from ntk.exceptions import NTKError from ntk.gateway import Gateway from ntk.utils import get_template_name, progress_bar @@ -49,12 +50,16 @@ 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 NTKError as error: + # Keep watching on a transient failure instead of killing 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) diff --git a/ntk/decorator.py b/ntk/decorator.py index f87a116..a814e38 100644 --- a/ntk/decorator.py +++ b/ntk/decorator.py @@ -1,6 +1,7 @@ import functools import logging -import sys + +from ntk.exceptions import NTKAuthError, NTKNotFoundError logging.basicConfig( format='%(asctime)s %(levelname)s %(message)s', @@ -38,12 +39,11 @@ def _wrapper(self, *func_args, **func_kwargs): response = func(self, *func_args, **func_kwargs) if response.status_code == 401: - logging.error(f'Invalid API key for {self.store}.') - sys.exit(1) + raise NTKAuthError(f'Invalid API key for {self.store}.') if response.status_code == 404: - logging.error(f'Theme or template not found for {self.store}.') - sys.exit(1) + 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 = "" diff --git a/ntk/exceptions.py b/ntk/exceptions.py new file mode 100644 index 0000000..ad67e43 --- /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 NTKConnectionError(NTKError): + """Raised when a request cannot reach the store after retries.""" diff --git a/ntk/gateway.py b/ntk/gateway.py index bda5149..44678ed 100644 --- a/ntk/gateway.py +++ b/ntk/gateway.py @@ -1,7 +1,20 @@ +import time import requests from urllib.parse import urljoin from ntk.decorator import check_error +from ntk.exceptions import NTKConnectionError + +MAX_RETRIES = 3 +RETRY_BACKOFF_SECONDS = 1 + +# 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: @@ -14,13 +27,21 @@ def _request(self, request_type, url, apikey=None, payload={}, files={}): if apikey: headers = {'Authorization': f'Bearer {apikey}'} - try: - response = requests.request(request_type, url, headers=headers, data=payload, files=files) - except requests.exceptions.ConnectionError: - return self._request(request_type, url, apikey, payload, files) - if response.status_code == 429 and "throttled" in response.content.decode(): - return self._request(request_type, url, apikey, payload, files) - return response + for attempt in range(MAX_RETRIES): + try: + response = requests.request(request_type, url, headers=headers, data=payload, files=files) + except TRANSIENT_EXCEPTIONS: + if attempt == MAX_RETRIES - 1: + raise NTKConnectionError(f'Unable to reach {self.store} after {MAX_RETRIES} attempts.') + time.sleep(RETRY_BACKOFF_SECONDS * (2 ** attempt)) + continue + + throttled = response.status_code == 429 and "throttled" in response.content.decode() + if throttled and attempt < MAX_RETRIES - 1: + time.sleep(RETRY_BACKOFF_SECONDS * (2 ** attempt)) + continue + + return response @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..e7cf86a 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -487,6 +487,21 @@ 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_when_push_raises_ntk_error(self, mock_push_templates): + """A transient NTKError during watch should be logged, not kill the watcher.""" + 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'), + ] + # Should not raise — the error is caught inside _handle_files_change. + with self.assertLogs(level='ERROR') as log: + self.command._handle_files_change(changes) + self.assertTrue( + any('Invalid API key for http://development.com.' in line for line in log.output)) + @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_gateway.py b/tests/test_gateway.py index 0165d24..73faba6 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, NTKConnectionError, NTKNotFoundError +from ntk.gateway import Gateway, MAX_RETRIES class TestGateway(unittest.TestCase): @@ -42,8 +45,9 @@ def test_request(self, mock_request): ] 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" @@ -82,6 +86,66 @@ def test_request_with_rate_limit_should_retry(self, mock_request): ] assert mock_request.mock_calls == expected_calls + @patch('ntk.gateway.time.sleep', autospec=True) + @patch('ntk.gateway.requests.request', autospec=True) + def test_request_rate_limit_gives_up_after_max_retries(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 + + response = self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + + assert mock_request.call_count == MAX_RETRIES + assert response is mock_response_429 + + @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 one of the broadened transient exceptions that should be retried, not surfaced raw. + mock_request.side_effect = requests.exceptions.Timeout() + + with self.assertRaises(NTKConnectionError): + self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + + assert mock_request.call_count == MAX_RETRIES + + ##### + # 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 ##### diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..19b88e7 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,34 @@ +import unittest +from unittest.mock import MagicMock, patch + +from ntk.__main__ import main +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() + + +if __name__ == '__main__': + unittest.main() From 5e5e1b9ca86f900531390f73892401ac7860c364 Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Wed, 29 Jul 2026 15:30:07 +0700 Subject: [PATCH 05/10] improve request timeout and error handling --- ntk/command.py | 4 ---- ntk/exceptions.py | 4 ++-- ntk/gateway.py | 41 ++++++++++++++++++++---------------- tests/test_gateway.py | 48 ++++++++++++++++++++++++++++--------------- 4 files changed, 56 insertions(+), 41 deletions(-) diff --git a/ntk/command.py b/ntk/command.py index 8a79690..2f6f5a5 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 @@ -90,7 +89,6 @@ 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 @@ -133,8 +131,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}') diff --git a/ntk/exceptions.py b/ntk/exceptions.py index ad67e43..d3125de 100644 --- a/ntk/exceptions.py +++ b/ntk/exceptions.py @@ -10,5 +10,5 @@ class NTKNotFoundError(NTKError): """Raised on a 404 response (store, theme, or template not found).""" -class NTKConnectionError(NTKError): - """Raised when a request cannot reach the store after retries.""" +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 44678ed..0b29588 100644 --- a/ntk/gateway.py +++ b/ntk/gateway.py @@ -1,12 +1,17 @@ +import logging import time import requests from urllib.parse import urljoin from ntk.decorator import check_error -from ntk.exceptions import NTKConnectionError +from ntk.exceptions import NTKRequestError MAX_RETRIES = 3 -RETRY_BACKOFF_SECONDS = 1 +# 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 = ( @@ -23,25 +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}'} + 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) - except TRANSIENT_EXCEPTIONS: - if attempt == MAX_RETRIES - 1: - raise NTKConnectionError(f'Unable to reach {self.store} after {MAX_RETRIES} attempts.') - time.sleep(RETRY_BACKOFF_SECONDS * (2 ** attempt)) - continue - - throttled = response.status_code == 429 and "throttled" in response.content.decode() - if throttled and attempt < MAX_RETRIES - 1: - time.sleep(RETRY_BACKOFF_SECONDS * (2 ** attempt)) - continue - - return response + 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_gateway.py b/tests/test_gateway.py index 73faba6..edf8a86 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -3,8 +3,8 @@ import requests -from ntk.exceptions import NTKAuthError, NTKConnectionError, NTKNotFoundError -from ntk.gateway import Gateway, MAX_RETRIES +from ntk.exceptions import NTKAuthError, NTKNotFoundError, NTKRequestError +from ntk.gateway import Gateway, MAX_RETRIES, REQUEST_INTERVAL_SECONDS, REQUEST_TIMEOUT class TestGateway(unittest.TestCase): @@ -41,7 +41,7 @@ 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 @@ -76,29 +76,29 @@ def test_request_with_rate_limit_should_retry(self, mock_request, mock_sleep): 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_rate_limit_gives_up_after_max_retries(self, mock_request, mock_sleep): + 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 - response = self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + with self.assertRaises(NTKRequestError): + self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) assert mock_request.call_count == MAX_RETRIES - assert response is mock_response_429 @patch('ntk.gateway.time.sleep', autospec=True) @patch('ntk.gateway.requests.request', autospec=True) @@ -116,13 +116,25 @@ def test_request_retries_transient_error_then_succeeds(self, mock_request, mock_ @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 one of the broadened transient exceptions that should be retried, not surfaced raw. + # 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.assertRaises(NTKConnectionError): - self.gateway._request('GET', 'http://simple.com/api/admin/themes/', apikey=self.apikey) + 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) @@ -168,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) #### @@ -195,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) ##### @@ -222,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) ##### @@ -250,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) ##### @@ -284,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) ##### @@ -312,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) From 5e18c11c1ced59dd31cc7349106922d9df1ab2a9 Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Wed, 29 Jul 2026 15:54:36 +0700 Subject: [PATCH 06/10] add support for api key as env variable --- ntk/__main__.py | 9 ++++ ntk/command.py | 7 ++-- ntk/conf.py | 14 ++++++- tests/test_command.py | 24 ++++++++--- tests/test_config.py | 96 +++++++++++++++++++++++++++++++++++++++---- tests/test_main.py | 11 +++++ 6 files changed, 141 insertions(+), 20 deletions(-) diff --git a/ntk/__main__.py b/ntk/__main__.py index 5da23e2..4125f46 100644 --- a/ntk/__main__.py +++ b/ntk/__main__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import logging import sys +from importlib.metadata import version, PackageNotFoundError from requests.exceptions import HTTPError @@ -14,7 +15,15 @@ ) +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: diff --git a/ntk/command.py b/ntk/command.py index 2f6f5a5..b57caa7 100644 --- a/ntk/command.py +++ b/ntk/command.py @@ -11,7 +11,7 @@ SASS_EXTENSIONS, ) from ntk.decorator import parser_config -from ntk.exceptions import NTKError +from ntk.exceptions import NTKRequestError from ntk.gateway import Gateway from ntk.utils import get_template_name, progress_bar @@ -56,8 +56,9 @@ def _handle_files_change(self, changes): elif event_type == Change.deleted: logging.info(f'[{self.config.env}] {event_type.name.title()} {template_name}') self._delete_templates([template_name]) - except NTKError as error: - # Keep watching on a transient failure instead of killing the watcher. + 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): diff --git a/ntk/conf.py b/ntk/conf.py index ae874f4..ce739c0 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,8 +131,11 @@ 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, + 'apikey': apikey, 'store': self.store, 'theme_id': self.theme_id, 'sass': { diff --git a/tests/test_command.py b/tests/test_command.py index e7cf86a..6663c9a 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -488,19 +488,31 @@ 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_when_push_raises_ntk_error(self, mock_push_templates): - """A transient NTKError during watch should be logged, not kill the watcher.""" - from ntk.exceptions import NTKAuthError - mock_push_templates.side_effect = NTKAuthError('Invalid API key for http://development.com.') + 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 error is caught inside _handle_files_change. + # 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('Invalid API key for http://development.com.' in line for line in log.output)) + 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) diff --git a/tests/test_config.py b/tests/test_config.py index d51fd4c..65037d2 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,91 @@ 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) diff --git a/tests/test_main.py b/tests/test_main.py index 19b88e7..4fa31fa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,6 +29,17 @@ def test_main_prints_help_hint_on_attribute_error(self, mock_parser): # 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)) + if __name__ == '__main__': unittest.main() From 58b157d197d039703ed6c52468a99904e08522fe Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Wed, 29 Jul 2026 16:03:31 +0700 Subject: [PATCH 07/10] improve test coverage --- tests/test_main.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_main.py b/tests/test_main.py index 4fa31fa..79f9fbb 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,7 +1,9 @@ import unittest from unittest.mock import MagicMock, patch -from ntk.__main__ import main +from importlib.metadata import PackageNotFoundError + +from ntk.__main__ import main, theme_kit_version from ntk.exceptions import NTKNotFoundError @@ -40,6 +42,11 @@ def test_main_logs_theme_kit_version(self, mock_parser): 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() From 1fe2f4a9276e055c32be86d4d98fceff48e5f7ce Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Wed, 29 Jul 2026 16:55:03 +0700 Subject: [PATCH 08/10] add tip for NTK_APIKEY support --- README.md | 3 +++ 1 file changed, 3 insertions(+) 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. From 6424c9f6d703d621a1f5cf6b0fb4026976a25c4e Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Wed, 29 Jul 2026 16:57:47 +0700 Subject: [PATCH 09/10] error on failed file uploads --- ntk/command.py | 9 ++++++--- tests/test_command.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ntk/command.py b/ntk/command.py index b57caa7..581e9ce 100644 --- a/ntk/command.py +++ b/ntk/command.py @@ -11,7 +11,7 @@ SASS_EXTENSIONS, ) from ntk.decorator import parser_config -from ntk.exceptions import NTKRequestError +from ntk.exceptions import NTKError, NTKRequestError from ntk.gateway import Gateway from ntk.utils import get_template_name, progress_bar @@ -91,7 +91,9 @@ def _push_templates(self, template_names, compile_sass=False): theme_id=self.config.theme_id, template_name=relative_pathfile, content=content, files=files) if not response.ok: - return + return False + + return True def _pull_templates(self, template_names): templates = [] @@ -188,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/tests/test_command.py b/tests/test_command.py index 6663c9a..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 From 5e85fe49ff3bed9df587776905b9774dec7263df Mon Sep 17 00:00:00 2001 From: Alex Phelps Date: Wed, 29 Jul 2026 17:14:13 +0700 Subject: [PATCH 10/10] improve api key handling in config.yml --- ntk/conf.py | 4 +++- tests/test_config.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/ntk/conf.py b/ntk/conf.py index ce739c0..b6ff190 100644 --- a/ntk/conf.py +++ b/ntk/conf.py @@ -135,13 +135,15 @@ def write_config(self): apikey = (configs.get(self.env) or {}).get('apikey') if self.apikey_from_env else self.apikey new_config = { - 'apikey': 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/tests/test_config.py b/tests/test_config.py index 65037d2..01fc957 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -249,3 +249,22 @@ def test_write_config_does_not_persist_env_apikey(self, mock_exists, mock_load, 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'])