diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25deee6..7fe51c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: env: PYTHONUNBUFFERED: 1 - FORCE_COLOR: 1 + NO_COLOR: 1 jobs: lint: @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' @@ -60,7 +60,7 @@ jobs: - name: Upload security reports if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: security-reports path: | @@ -76,20 +76,15 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] - exclude: - # Skip some combinations to reduce CI time - - os: windows-latest - python-version: '3.7' - - os: macos-latest - python-version: '3.7' + python-version: ['3.10', '3.11', '3.12'] + # No exclusions needed currently steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -101,11 +96,11 @@ jobs: - name: Run tests with pytest run: | - pytest -v --tb=short --cov=claude_setup --cov-report=xml --cov-report=term-missing + pytest -v --tb=short --cov=src/claude_setup --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.11' - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml flags: unittests @@ -123,7 +118,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' @@ -147,7 +142,7 @@ jobs: claude-bedrock-setup --version - name: Upload build artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ @@ -165,13 +160,13 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' - name: Download build artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 538b62c..7505e5f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,13 +28,13 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} queries: security-extended,security-and-quality - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' @@ -45,6 +45,6 @@ jobs: pip install -e .[dev,test] - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" \ No newline at end of file diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index d150798..a054a75 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' @@ -97,7 +97,7 @@ jobs: - name: Upload security reports if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: security-reports path: | @@ -120,7 +120,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 448a1a7..bf66064 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' @@ -94,7 +94,7 @@ jobs: pytest -v --cov=claude_setup --cov-report=xml --cov-fail-under=90 - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: file: ./coverage.xml flags: release @@ -113,7 +113,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' @@ -153,7 +153,7 @@ jobs: claude-bedrock-setup --version - name: Upload build artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: release-distributions path: dist/ @@ -174,7 +174,7 @@ jobs: fetch-depth: 0 - name: Download build artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: release-distributions path: dist/ @@ -257,7 +257,7 @@ jobs: steps: - name: Download build artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: release-distributions path: dist/ @@ -286,7 +286,7 @@ jobs: steps: - name: Download build artifacts - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: release-distributions path: dist/ diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index ceb1599..fbc87cb 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -56,7 +56,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' @@ -198,7 +198,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' diff --git a/Pipfile b/Pipfile index 5ea00b6..dca79a9 100644 --- a/Pipfile +++ b/Pipfile @@ -13,7 +13,6 @@ claude-setup = {file = ".", editable = true} [dev-packages] black = "*" flake8 = "*" -mypy = "*" pytest = "*" pytest-cov = "*" pytest-mock = "*" diff --git a/README.md b/README.md index 08214d0..60a7e19 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A command-line tool to configure Claude Desktop to use AWS Bedrock as its AI pro ## Prerequisites -- Python 3.7 or higher +- Python 3.10 or higher - AWS CLI configured with valid credentials - AWS account with access to Amazon Bedrock - Claude Desktop application installed diff --git a/GITHUB_SECRETS_SETUP.md b/docs/GITHUB_SECRETS_SETUP.md similarity index 100% rename from GITHUB_SECRETS_SETUP.md rename to docs/GITHUB_SECRETS_SETUP.md diff --git a/TESTING.md b/docs/TESTING.md similarity index 100% rename from TESTING.md rename to docs/TESTING.md diff --git a/claude-bedrock-setup-implementation-plan.md b/docs/claude-bedrock-setup-implementation-plan.md similarity index 100% rename from claude-bedrock-setup-implementation-plan.md rename to docs/claude-bedrock-setup-implementation-plan.md diff --git a/claude-setup-pypi-distribution-plan.md b/docs/claude-setup-pypi-distribution-plan.md similarity index 100% rename from claude-setup-pypi-distribution-plan.md rename to docs/claude-setup-pypi-distribution-plan.md diff --git a/docs/console.rst b/docs/console.rst new file mode 100644 index 0000000..1ee3e78 --- /dev/null +++ b/docs/console.rst @@ -0,0 +1,437 @@ +Console API +=========== + +For complete control over terminal formatting, Rich offers a :class:`~rich.console.Console` class. Most applications will require a single Console instance, so you may want to create one at the module level or as an attribute of your top-level object. For example, you could add a file called "console.py" to your project:: + + from rich.console import Console + console = Console() + +Then you can import the console from anywhere in your project like this:: + + from my_project.console import console + +The console object handles the mechanics of generating ANSI escape sequences for color and style. It will auto-detect the capabilities of the terminal and convert colors if necessary. + + +Attributes +---------- + +The console will auto-detect a number of properties required when rendering. + +* :obj:`~rich.console.Console.size` is the current dimensions of the terminal (which may change if you resize the window). +* :obj:`~rich.console.Console.encoding` is the default encoding (typically "utf-8"). +* :obj:`~rich.console.Console.is_terminal` is a boolean that indicates if the Console instance is writing to a terminal or not. +* :obj:`~rich.console.Console.color_system` is a string containing the Console color system (see below). + + +Color systems +------------- + +There are several "standards" for writing color to the terminal which are not all universally supported. Rich will auto-detect the appropriate color system, or you can set it manually by supplying a value for ``color_system`` to the :class:`~rich.console.Console` constructor. + +You can set ``color_system`` to one of the following values: + +* ``None`` Disables color entirely. +* ``"auto"`` Will auto-detect the color system. +* ``"standard"`` Can display 8 colors, with normal and bright variations, for 16 colors in total. +* ``"256"`` Can display the 16 colors from "standard" plus a fixed palette of 240 colors. +* ``"truecolor"`` Can display 16.7 million colors, which is likely all the colors your monitor can display. +* ``"windows"`` Can display 8 colors in legacy Windows terminal. New Windows terminal can display "truecolor". + +.. warning:: + Be careful when setting a color system, if you set a higher color system than your terminal supports, your text may be unreadable. + + +Printing +-------- + +To write rich content to the terminal use the :meth:`~rich.console.Console.print` method. Rich will convert any object to a string via its (``__str__``) method and perform some simple syntax highlighting. It will also do pretty printing of any containers, such as dicts and lists. If you print a string it will render :ref:`console_markup`. Here are some examples:: + + console.print([1, 2, 3]) + console.print("[blue underline]Looks like a link") + console.print(locals()) + console.print("FOO", style="white on blue") + +You can also use :meth:`~rich.console.Console.print` to render objects that support the :ref:`protocol`, which includes Rich's built-in objects such as :class:`~rich.text.Text`, :class:`~rich.table.Table`, and :class:`~rich.syntax.Syntax` -- or other custom objects. + + +Logging +------- + +The :meth:`~rich.console.Console.log` method offers the same capabilities as print, but adds some features useful for debugging a running application. Logging writes the current time in a column to the left, and the file and line where the method was called to a column on the right. Here's an example:: + + >>> console.log("Hello, World!") + +.. raw:: html + +
[16:32:08] Hello, World!                                         <stdin>:1
+    
+ +To help with debugging, the log() method has a ``log_locals`` parameter. If you set this to ``True``, Rich will display a table of local variables where the method was called. + + +Printing JSON +------------- + +The :meth:`~rich.console.Console.print_json` method will pretty print (format and style) a string containing JSON. Here's a short example:: + + console.print_json('[false, true, null, "foo"]') + +You can also *log* json by logging a :class:`~rich.json.JSON` object:: + + from rich.json import JSON + console.log(JSON('["foo", "bar"]')) + +Because printing JSON is a common requirement, you may import ``print_json`` from the main namespace:: + + from rich import print_json + +You can also pretty print JSON via the command line with the following:: + + python -m rich.json cats.json + + +Low level output +---------------- + +In additional to :meth:`~rich.console.Console.print` and :meth:`~rich.console.Console.log`, Rich has an :meth:`~rich.console.Console.out` method which provides a lower-level way of writing to the terminal. The out() method converts all the positional arguments to strings and won't pretty print, word wrap, or apply markup to the output, but can apply a basic style and will optionally do highlighting. + +Here's an example:: + + >>> console.out("Locals", locals()) + + +Rules +----- + +The :meth:`~rich.console.Console.rule` method will draw a horizontal line with an optional title, which is a good way of dividing your terminal output into sections. + + >>> console.rule("[bold red]Chapter 2") + +.. raw:: html + +
─────────────────────────────── Chapter 2 ───────────────────────────────
+ +The rule method also accepts a ``style`` parameter to set the style of the line, and an ``align`` parameter to align the title ("left", "center", or "right"). + + +Status +------ + +Rich can display a status message with a 'spinner' animation that won't interfere with regular console output. Run the following command for a demo of this feature:: + + python -m rich.status + +To display a status message, call :meth:`~rich.console.Console.status` with the status message (which may be a string, Text, or other renderable). The result is a context manager which starts and stops the status display around a block of code. Here's an example:: + + with console.status("Working..."): + do_work() + +You can change the spinner animation via the ``spinner`` parameter:: + + with console.status("Monkeying around...", spinner="monkey"): + do_work() + +Run the following command to see the available choices for ``spinner``:: + + python -m rich.spinner + + +Justify / Alignment +------------------- + +Both print and log support a ``justify`` argument which if set must be one of "default", "left", "right", "center", or "full". If "left", any text printed (or logged) will be left aligned, if "right" text will be aligned to the right of the terminal, if "center" the text will be centered, and if "full" the text will be lined up with both the left and right edges of the terminal (like printed text in a book). + +The default for ``justify`` is ``"default"`` which will generally look the same as ``"left"`` but with a subtle difference. Left justify will pad the right of the text with spaces, while a default justify will not. You will only notice the difference if you set a background color with the ``style`` argument. The following example demonstrates the difference:: + + from rich.console import Console + + console = Console(width=20) + + style = "bold white on blue" + console.print("Rich", style=style) + console.print("Rich", style=style, justify="left") + console.print("Rich", style=style, justify="center") + console.print("Rich", style=style, justify="right") + + +This produces the following output: + +.. raw:: html + +
Rich
+    Rich                
+            Rich        
+                    Rich
+    
+ +Overflow +-------- + +Overflow is what happens when text you print is larger than the available space. Overflow may occur if you print long 'words' such as URLs for instance, or if you have text inside a panel or table cell with restricted space. + +You can specify how Rich should handle overflow with the ``overflow`` argument to :meth:`~rich.console.Console.print` which should be one of the following strings: "fold", "crop", "ellipsis", or "ignore". The default is "fold" which will put any excess characters on the following line, creating as many new lines as required to fit the text. + +The "crop" method truncates the text at the end of the line, discarding any characters that would overflow. + +The "ellipsis" method is similar to "crop", but will insert an ellipsis character ("…") at the end of any text that has been truncated. + +The following code demonstrates the basic overflow methods:: + + from typing import List + from rich.console import Console, OverflowMethod + + console = Console(width=14) + supercali = "supercalifragilisticexpialidocious" + + overflow_methods: List[OverflowMethod] = ["fold", "crop", "ellipsis"] + for overflow in overflow_methods: + console.rule(overflow) + console.print(supercali, overflow=overflow, style="bold blue") + console.print() + +This produces the following output: + +.. raw:: html + +
──── fold ────
+    supercalifragi
+    listicexpialid
+    ocious
+    
+    ──── crop ────
+    supercalifragi
+    
+    ── ellipsis ──
+    supercalifrag…
+    
+    
+ +You can also set overflow to "ignore" which allows text to run on to the next line. In practice this will look the same as "crop" unless you also set ``crop=False`` when calling :meth:`~rich.console.Console.print`. + + +Console style +------------- + +The Console has a ``style`` attribute which you can use to apply a style to everything you print. By default ``style`` is None meaning no extra style is applied, but you can set it to any valid style. Here's an example of a Console with a style attribute set:: + + from rich.console import Console + blue_console = Console(style="white on blue") + blue_console.print("I'm blue. Da ba dee da ba di.") + + +Soft Wrapping +------------- + +Rich word wraps text you print by inserting line breaks. You can disable this behavior by setting ``soft_wrap=True`` when calling :meth:`~rich.console.Console.print`. With *soft wrapping* enabled any text that doesn't fit will run on to the following line(s), just like the built-in ``print``. + + +Cropping +-------- + +The :meth:`~rich.console.Console.print` method has a boolean ``crop`` argument. The default value for crop is True which tells Rich to crop any content that would otherwise run on to the next line. You generally don't need to think about cropping, as Rich will resize content to fit within the available width. + +.. note:: + Cropping is automatically disabled if you print with ``soft_wrap=True``. + + +Input +----- + +The console class has an :meth:`~rich.console.Console.input` method which works in the same way as Python's built-in :func:`input` function, but can use anything that Rich can print as a prompt. For example, here's a colorful prompt with an emoji:: + + from rich.console import Console + console = Console() + console.input("What is [i]your[/i] [bold red]name[/]? :smiley: ") + +If Python's builtin :mod:`readline` module is previously loaded, elaborate line editing and history features will be available. + +Exporting +--------- + +The Console class can export anything written to it as either text, svg, or html. To enable exporting, first set ``record=True`` on the constructor. This tells Rich to save a copy of any data you ``print()`` or ``log()``. Here's an example:: + + from rich.console import Console + console = Console(record=True) + +After you have written content, you can call :meth:`~rich.console.Console.export_text`, :meth:`~rich.console.Console.export_svg` or :meth:`~rich.console.Console.export_html` to get the console output as a string. You can also call :meth:`~rich.console.Console.save_text`, :meth:`~rich.console.Console.save_svg`, or :meth:`~rich.console.Console.save_html` to write the contents directly to disk. + +For examples of the html output generated by Rich Console, see :ref:`appendix-colors`. + +Exporting SVGs +^^^^^^^^^^^^^^ + +When using :meth:`~rich.console.Console.export_svg` or :meth:`~rich.console.Console.save_svg`, the width of the SVG will match the width of your terminal window (in terms of characters), while the height will scale automatically to accommodate the console output. + +You can open the SVG in a web browser. You can also insert it in to a webpage with an ```` tag or by copying the markup in to your HTML. + +The image below shows an example of an SVG exported by Rich. + +.. image:: ../images/svg_export.svg + +You can customize the theme used during SVG export by importing the desired theme from the :mod:`rich.terminal_theme` module and passing it to :meth:`~rich.console.Console.export_svg` or :meth:`~rich.console.Console.save_svg` via the ``theme`` parameter:: + + + from rich.console import Console + from rich.terminal_theme import MONOKAI + + console = Console(record=True) + console.save_svg("example.svg", theme=MONOKAI) + +Alternatively, you can create a theme of your own by constructing a :class:`rich.terminal_theme.TerminalTheme` instance yourself and passing that in. + +.. note:: + The SVGs reference the Fira Code font. If you embed a Rich SVG in your page, you may also want to add a link to the `Fira Code CSS `_ + +Error console +------------- + +The Console object will write to ``sys.stdout`` by default (so that you see output in the terminal). If you construct the Console with ``stderr=True`` Rich will write to ``sys.stderr``. You may want to use this to create an *error console* so you can split error messages from regular output. Here's an example:: + + from rich.console import Console + error_console = Console(stderr=True) + +You might also want to set the ``style`` parameter on the Console to make error messages visually distinct. Here's how you might do that:: + + error_console = Console(stderr=True, style="bold red") + +File output +----------- + +You can tell the Console object to write to a file by setting the ``file`` argument on the constructor -- which should be a file-like object opened for writing text. You could use this to write to a file without the output ever appearing on the terminal. Here's an example:: + + import sys + from rich.console import Console + from datetime import datetime + + with open("report.txt", "wt") as report_file: + console = Console(file=report_file) + console.rule(f"Report Generated {datetime.now().ctime()}") + +Note that when writing to a file you may want to explicitly set the ``width`` argument if you don't want to wrap the output to the current console width. + +Capturing output +---------------- + +There may be situations where you want to *capture* the output from a Console rather than writing it directly to the terminal. You can do this with the :meth:`~rich.console.Console.capture` method which returns a context manager. On exit from this context manager, call :meth:`~rich.console.Capture.get` to return the string that would have been written to the terminal. Here's an example:: + + from rich.console import Console + console = Console() + with console.capture() as capture: + console.print("[bold red]Hello[/] World") + str_output = capture.get() + +An alternative way of capturing output is to set the Console file to a :py:class:`io.StringIO`. This is the recommended method if you are testing console output in unit tests. Here's an example:: + + from io import StringIO + from rich.console import Console + console = Console(file=StringIO()) + console.print("[bold red]Hello[/] World") + str_output = console.file.getvalue() + +Paging +------ + +If you have some long output to present to the user you can use a *pager* to display it. A pager is typically an application on your operating system which will at least support pressing a key to scroll, but will often support scrolling up and down through the text and other features. + +You can page output from a Console by calling :meth:`~rich.console.Console.pager` which returns a context manager. When the pager exits, anything that was printed will be sent to the pager. Here's an example:: + + from rich.__main__ import make_test_card + from rich.console import Console + + console = Console() + with console.pager(): + console.print(make_test_card()) + +Since the default pager on most platforms don't support color, Rich will strip color from the output. If you know that your pager supports color, you can set ``styles=True`` when calling the :meth:`~rich.console.Console.pager` method. + +.. note:: + Rich will look at ``MANPAGER`` then the ``PAGER`` environment variables (``MANPAGER`` takes priority) to get the pager command. On Linux and macOS you can set one of these to ``less -r`` to enable paging with ANSI styles. + +Alternate screen +---------------- + +.. warning:: + This feature is currently experimental. You might want to wait before using it in production. + +Terminals support an 'alternate screen' mode which is separate from the regular terminal and allows for full-screen applications that leave your stream of input and commands intact. Rich supports this mode via the :meth:`~rich.console.Console.set_alt_screen` method, although it is recommended that you use :meth:`~rich.console.Console.screen` which returns a context manager that disables alternate mode on exit. + +Here's an example of an alternate screen:: + + from time import sleep + from rich.console import Console + + console = Console() + with console.screen(): + console.print(locals()) + sleep(5) + +The above code will display a pretty printed dictionary on the alternate screen before returning to the command prompt after 5 seconds. + +You can also provide a renderable to :meth:`~rich.console.Console.screen` which will be displayed in the alternate screen when you call :meth:`~rich.ScreenContext.update`. + +Here's an example:: + + from time import sleep + + from rich.console import Console + from rich.align import Align + from rich.text import Text + from rich.panel import Panel + + console = Console() + + with console.screen(style="bold white on red") as screen: + for count in range(5, 0, -1): + text = Align.center( + Text.from_markup(f"[blink]Don't Panic![/blink]\n{count}", justify="center"), + vertical="middle", + ) + screen.update(Panel(text)) + sleep(1) + +Updating the screen with a renderable allows Rich to crop the contents to fit the screen without scrolling. + +For a more powerful way of building full screen interfaces with Rich, see :ref:`live`. + + +.. note:: + If you ever find yourself stuck in alternate mode after exiting Python code, type ``reset`` in the terminal + +Terminal detection +------------------ + +If Rich detects that it is not writing to a terminal it will strip control codes from the output. If you want to write control codes to a regular file then set ``force_terminal=True`` on the constructor. + +Letting Rich auto-detect terminals is useful as it will write plain text when you pipe output to a file or other application. + +Interactive mode +---------------- + +Rich will remove animations such as progress bars and status indicators when not writing to a terminal as you probably don't want to write these out to a text file (for example). You can override this behavior by setting the ``force_interactive`` argument on the constructor. Set it to True to enable animations or False to disable them. + +.. note:: + Some CI systems support ANSI color and style but not anything that moves the cursor or selectively refreshes parts of the terminal. For these you might want to set ``force_terminal`` to ``True`` and ``force_interactive`` to ``False``. + +Environment variables +--------------------- + +Rich respects some standard environment variables. + +Setting the environment variable ``TERM`` to ``"dumb"`` or ``"unknown"`` will disable color/style and some features that require moving the cursor, such as progress bars. + +If the environment variable ``FORCE_COLOR`` is set and non-empty, then color/styles will be enabled regardless of the value of ``TERM``. + +If the environment variable ``NO_COLOR`` is set, Rich will disable all color in the output. ``NO_COLOR`` takes precedence over ``FORCE_COLOR``. See `no_color `_ for details. + +.. note:: + The ``NO_COLOR`` environment variable removes *color* only. Styles such as dim, bold, italic, underline etc. are preserved. + +The environment variable ``TTY_COMPATIBLE`` is used to override Rich's auto-detection of terminal support. If ``TTY_COMPATIBLE`` is set to ``1`` then Rich will assume it is writing to a device which can handle escape sequences like a terminal. If ``TTY_COMPATIBLE`` is set to ``"0"``, then Rich will assume that it is writing to a device that is *not* capable of displaying escape sequences (such as a regular file). If the variable is not set, or set to a value other than "0" or "1", then Rich will attempt to auto-detect terminal support. + +.. note:: + If you want Rich output in CI or Github Actions, then you should set ``TTY_COMPATIBLE=1``. + +If ``width`` / ``height`` arguments are not explicitly provided as arguments to ``Console`` then the environment variables ``COLUMNS`` / ``LINES`` can be used to set the console width / height. ``JUPYTER_COLUMNS`` / ``JUPYTER_LINES`` behave similarly and are used in Jupyter. + +Note that environment variables set defaults in the Console object. If you explicitly set any variables in the constructor then these will take precedence. \ No newline at end of file diff --git a/docs/highlighting.rst b/docs/highlighting.rst new file mode 100644 index 0000000..3de54c1 --- /dev/null +++ b/docs/highlighting.rst @@ -0,0 +1,68 @@ +.. _highlighting: + +Highlighting +============ + +Rich will automatically highlight patterns in text, such as numbers, strings, collections, booleans, None, and a few more exotic patterns such as file paths, URLs and UUIDs. + +You can disable highlighting either by setting ``highlight=False`` on :meth:`~rich.console.Console.print` or :meth:`~rich.console.Console.log`, or by setting ``highlight=False`` on the :class:`~rich.console.Console` constructor which disables it everywhere. If you disable highlighting on the constructor, you can still selectively *enable* highlighting with ``highlight=True`` on print / log. + +Custom Highlighters +------------------- + +If the default highlighting doesn't fit your needs, you can define a custom highlighter. The easiest way to do this is to extend the :class:`~rich.highlighter.RegexHighlighter` class which applies a style to any text matching a list of regular expressions. + +Here's an example which highlights text that looks like an email address:: + + from rich.console import Console + from rich.highlighter import RegexHighlighter + from rich.theme import Theme + + class EmailHighlighter(RegexHighlighter): + """Apply style to anything that looks like an email.""" + + base_style = "example." + highlights = [r"(?P[\w-]+@([\w-]+\.)+[\w-]+)"] + + + theme = Theme({"example.email": "bold magenta"}) + console = Console(highlighter=EmailHighlighter(), theme=theme) + console.print("Send funds to money@example.org") + + +The ``highlights`` class variable should contain a list of regular expressions. The group names of any matching expressions are prefixed with the ``base_style`` attribute and used as styles for matching text. In the example above, any email addresses will have the style "example.email" applied, which we've defined in a custom :ref:`Theme `. + +Setting the highlighter on the Console will apply highlighting to all text you print (if enabled). You can also use a highlighter on a more granular level by using the instance as a callable and printing the result. For example, we could use the email highlighter class like this:: + + + console = Console(theme=theme) + highlight_emails = EmailHighlighter() + console.print(highlight_emails("Send funds to money@example.org")) + + +While :class:`~rich.highlighter.RegexHighlighter` is quite powerful, you can also extend its base class :class:`~rich.highlighter.Highlighter` to implement a custom scheme for highlighting. It contains a single method :class:`~rich.highlighter.Highlighter.highlight` which is passed the :class:`~rich.text.Text` to highlight. + +Here's a silly example that highlights every character with a different color:: + + from random import randint + + from rich import print + from rich.highlighter import Highlighter + + + class RainbowHighlighter(Highlighter): + def highlight(self, text): + for index in range(len(text)): + text.stylize(f"color({randint(16, 255)})", index, index + 1) + + + rainbow = RainbowHighlighter() + print(rainbow("I must not fear. Fear is the mind-killer.")) + +Builtin Highlighters +-------------------- + +The following builtin highlighters are available. + +* :class:`~rich.highlighter.ISO8601Highlighter` Highlights ISO8601 date time strings. +* :class:`~rich.highlighter.JSONHighlighter` Highlights JSON formatted strings. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e99afd0..e42c878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,9 +22,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -46,7 +43,7 @@ keywords = [ "llm", "chatbot", ] -requires-python = ">=3.7" +requires-python = ">=3.10" dependencies = [ "click>=8.1.0", "boto3>=1.34.0", @@ -98,7 +95,7 @@ claude_setup = ["py.typed"] [tool.black] line-length = 88 -target-version = ['py37', 'py38', 'py39', 'py310', 'py311', 'py312'] +target-version = ['py310', 'py311', 'py312'] include = '\.pyi?$' extend-exclude = ''' /( @@ -115,7 +112,7 @@ extend-exclude = ''' ''' [tool.mypy] -python_version = "3.7" +python_version = "3.10" warn_return_any = true warn_unused_configs = true warn_redundant_casts = true diff --git a/run_tests.py b/run_tests.py index f83cd70..cfeb1f1 100755 --- a/run_tests.py +++ b/run_tests.py @@ -10,24 +10,26 @@ def run_tests(): """Run the test suite with coverage reporting.""" print("Running claude-setup test suite...") print("=" * 60) - + # Change to project directory project_dir = Path(__file__).parent - + try: # Run tests with coverage cmd = [ - "pipenv", "run", "pytest", + "pipenv", + "run", + "pytest", "tests/", - "-v", + "-v", "--cov=src/claude_setup", "--cov-report=term-missing", "--cov-report=html:htmlcov", - "--cov-fail-under=95" + "--cov-fail-under=95", ] - + result = subprocess.run(cmd, cwd=project_dir, check=False) - + if result.returncode == 0: print("\n" + "=" * 60) print("✅ All tests passed! Coverage target met.") @@ -36,7 +38,7 @@ def run_tests(): print("\n" + "=" * 60) print("❌ Some tests failed or coverage target not met.") sys.exit(1) - + except FileNotFoundError: print("❌ Error: pipenv not found. Please install pipenv first.") sys.exit(1) @@ -46,4 +48,4 @@ def run_tests(): if __name__ == "__main__": - run_tests() \ No newline at end of file + run_tests() diff --git a/setup.py b/setup.py index 6406318..d34e9fe 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,55 @@ +"""setup.py - Setup script for the Claude Bedrock Setup CLI tool.""" + import os +import re from setuptools import setup, find_packages -# Read version from __init__.py + def get_version(): - version = {} - with open(os.path.join('src', 'claude_setup', '__init__.py')) as f: - exec(f.read(), version) - return version['__version__'] + """Extract version from src/claude_setup/_version.py.""" + version_file = os.path.join("src", "claude_setup", "_version.py") + with open(version_file, "r", encoding="utf-8") as f: + content = f.read() + match = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', content, re.MULTILINE) + if match: + return match.group(1) + raise RuntimeError("Unable to find __version__ in _version.py") + # Read long description from README.md def get_long_description(): + """Read the long description from README.md.""" try: - with open('README.md', 'r', encoding='utf-8') as f: + with open("README.md", "r", encoding="utf-8") as f: return f.read() except FileNotFoundError: - return "A command-line tool to configure Claude Desktop to use AWS Bedrock as its AI provider." + return ( + "A command-line tool to configure Claude Desktop to use " + "AWS Bedrock as its AI provider." + ) + setup( name="claude-bedrock-setup", version=get_version(), author="Chris Christensen", - author_email="chris.christensen@example.com", + author_email="chris.christensen@nexusweblabs.com", description="CLI tool to configure Claude Desktop for AWS Bedrock", long_description=get_long_description(), long_description_content_type="text/markdown", url="https://github.com/christensen143/claude-bedrock-setup", project_urls={ - "Bug Tracker": "https://github.com/christensen143/claude-bedrock-setup/issues", - "Documentation": "https://github.com/christensen143/claude-bedrock-setup#readme", - "Source Code": "https://github.com/christensen143/claude-bedrock-setup", - "Changelog": "https://github.com/christensen143/claude-bedrock-setup/blob/main/CHANGELOG.md", + "Bug Tracker": ( + "https://github.com/christensen143/" "claude-bedrock-setup/issues" + ), + "Documentation": ( + "https://github.com/christensen143/" "claude-bedrock-setup#readme" + ), + "Source Code": ("https://github.com/christensen143/" "claude-bedrock-setup"), + "Changelog": ( + "https://github.com/christensen143/" + "claude-bedrock-setup/blob/main/CHANGELOG.md" + ), }, packages=find_packages(where="src"), package_dir={"": "src"}, @@ -40,9 +60,6 @@ def get_long_description(): "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -64,7 +81,7 @@ def get_long_description(): "llm", "chatbot", ], - python_requires=">=3.7", + python_requires=">=3.10", install_requires=[ "click>=8.1.0", "boto3>=1.34.0", @@ -95,4 +112,4 @@ def get_long_description(): }, include_package_data=True, zip_safe=False, -) \ No newline at end of file +) diff --git a/src/claude_setup/__init__.py b/src/claude_setup/__init__.py index 050e4e4..9d962eb 100644 --- a/src/claude_setup/__init__.py +++ b/src/claude_setup/__init__.py @@ -6,28 +6,21 @@ and making it easy to get started with Claude on AWS. """ -__version__ = "0.1.0" +from __future__ import annotations + +from ._version import __version__ + __author__ = "Chris Christensen" __author_email__ = "chris@nexusweblabs.com" __license__ = "MIT" __description__ = "CLI tool to configure Claude Desktop for AWS Bedrock" __url__ = "https://github.com/christensen143/claude-bedrock-setup" -# Lazy imports to avoid import issues during setup -def __getattr__(name): - if name == "cli": - from .cli import cli - return cli - elif name == "ConfigManager": - from .config_manager import ConfigManager - return ConfigManager - elif name == "AuthChecker": - from .auth_checker import AuthChecker - return AuthChecker - elif name == "AWSClient": - from .aws_client import AWSClient - return AWSClient - raise AttributeError(f"module '{__name__}' has no attribute '{name}'") +# Module-level imports for __all__ exports +from .cli import cli +from .config_manager import ConfigManager +from .aws_client import BedrockClient + __all__ = [ "__version__", @@ -38,6 +31,5 @@ def __getattr__(name): "__url__", "cli", "ConfigManager", - "AuthChecker", - "AWSClient", -] \ No newline at end of file + "BedrockClient", +] diff --git a/src/claude_setup/_version.py b/src/claude_setup/_version.py new file mode 100644 index 0000000..5005812 --- /dev/null +++ b/src/claude_setup/_version.py @@ -0,0 +1,3 @@ +"""Version information for claude-setup package.""" + +__version__ = "0.1.0" diff --git a/src/claude_setup/auth_checker.py b/src/claude_setup/auth_checker.py index 8f38c9a..278edb4 100644 --- a/src/claude_setup/auth_checker.py +++ b/src/claude_setup/auth_checker.py @@ -1,12 +1,18 @@ +from __future__ import annotations + import subprocess -def check_aws_auth(): +def check_aws_auth() -> bool: """Check if AWS credentials are properly configured""" try: # Use AWS CLI to verify credentials - result = subprocess.run(['aws', 'sts', 'get-caller-identity'], - capture_output=True, text=True, check=True) + subprocess.run( + ["aws", "sts", "get-caller-identity"], + capture_output=True, + text=True, + check=True, + ) return True except subprocess.CalledProcessError: return False @@ -14,4 +20,4 @@ def check_aws_auth(): # AWS CLI not installed return False except Exception: - return False \ No newline at end of file + return False diff --git a/src/claude_setup/aws_client.py b/src/claude_setup/aws_client.py index 71a7589..75ed6ae 100644 --- a/src/claude_setup/aws_client.py +++ b/src/claude_setup/aws_client.py @@ -1,48 +1,65 @@ +from __future__ import annotations + import subprocess import json from typing import List, Dict class BedrockClient: - def __init__(self, region: str = 'us-west-2'): + def __init__(self, region: str = "us-west-2"): self.region = region - + def list_claude_models(self) -> List[Dict[str, str]]: """List available Claude models with inference profiles""" try: # Use AWS CLI directly to avoid credential issues - cmd = ['aws', 'bedrock', 'list-inference-profiles', '--region', self.region] + cmd = [ + "aws", + "bedrock", + "list-inference-profiles", + "--region", + self.region, + ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) response = json.loads(result.stdout) - + models = [] - for profile in response.get('inferenceProfileSummaries', []): - profile_id = profile.get('inferenceProfileId', '') - profile_name = profile.get('inferenceProfileName', '') - + for profile in response.get("inferenceProfileSummaries", []): + profile_id = profile.get("inferenceProfileId", "") + profile_name = profile.get("inferenceProfileName", "") + # Filter for Claude models - if 'anthropic.claude' in profile_id: + if "anthropic.claude" in profile_id: # Extract model info from the profile ID - model_name = profile_name or profile_id.split('/')[-1] - - models.append({ - 'id': profile_id, - 'name': model_name, - 'arn': profile.get('inferenceProfileArn', ''), - 'status': profile.get('status', 'ACTIVE') - }) - + model_name = profile_name or profile_id.split("/")[-1] + + models.append( + { + "id": profile_id, + "name": model_name, + "arn": profile.get("inferenceProfileArn", ""), + "status": profile.get("status", "ACTIVE"), + } + ) + # Sort models by name - models.sort(key=lambda x: x['name']) - + models.sort(key=lambda x: x["name"]) + return models - + except subprocess.CalledProcessError as e: - if 'AccessDeniedException' in e.stderr: - raise Exception("Access denied. Please check your AWS permissions for Amazon Bedrock.") - elif 'not authorized' in e.stderr: - raise Exception("Not authenticated with AWS. Please run 'aws configure' or set up your AWS credentials.") + if "AccessDeniedException" in e.stderr: + raise Exception( + "Access denied. Please check your AWS " + "permissions for Amazon Bedrock." + ) + elif "not authorized" in e.stderr: + raise Exception( + "Not authenticated with AWS. Please run " + "'aws configure' or set up your AWS " + "credentials." + ) else: raise Exception(f"Error listing models: {e.stderr}") except Exception as e: - raise Exception(f"Unexpected error: {str(e)}") \ No newline at end of file + raise Exception(f"Unexpected error: {str(e)}") diff --git a/src/claude_setup/cli.py b/src/claude_setup/cli.py index e3cb196..355bbaf 100644 --- a/src/claude_setup/cli.py +++ b/src/claude_setup/cli.py @@ -1,3 +1,6 @@ +from __future__ import annotations + +import os import click from rich.console import Console from rich.panel import Panel @@ -9,60 +12,77 @@ from .config_manager import ConfigManager from .gitignore_manager import ensure_gitignore -console = Console() +# Create console - disable color when NO_COLOR is set or in tests +# Rich needs force_terminal=False to prevent any ANSI codes +no_color = os.environ.get("NO_COLOR") or os.environ.get("PYTEST_CURRENT_TEST") +if no_color: + # Force non-terminal mode to ensure no ANSI codes at all + console = Console(force_terminal=False, no_color=True) +else: + console = Console() @click.group() @click.version_option(version="0.1.0", prog_name="claude-bedrock-setup") -def cli(): +def cli() -> None: """Claude Bedrock Setup CLI - Configure Claude to use AWS Bedrock""" pass @cli.command() -@click.option('--region', default='us-west-2', help='AWS region (default: us-west-2)') -@click.option('--non-interactive', is_flag=True, help='Run in non-interactive mode') -def setup(region, non_interactive): +@click.option("--region", default="us-west-2", help="AWS region (default: us-west-2)") +@click.option("--non-interactive", is_flag=True, help="Run in non-interactive mode") +def setup(region: str, non_interactive: bool) -> None: """Set up Claude to use AWS Bedrock""" - console.print(Panel.fit( - Text("Claude Bedrock Setup", style="bold blue"), - subtitle="Configure Claude to use AWS Bedrock" - )) - + console.print( + Panel.fit( + Text("Claude Bedrock Setup", style="bold blue"), + subtitle="Configure Claude to use AWS Bedrock", + ) + ) + # Check AWS authentication console.print("\n[yellow]Checking AWS authentication...[/yellow]") if not check_aws_auth(): console.print("[red]✗ Not authenticated with AWS[/red]") - console.print("\nPlease authenticate with AWS using one of these methods:") + console.print("\nPlease authenticate with AWS using one of these " "methods:") console.print(" • aws configure") - console.print(" • Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables") + console.print( + " • Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY " + "environment variables" + ) console.print(" • Use AWS SSO: aws sso login") console.print("\nThen run this command again.") sys.exit(1) - + console.print("[green]✓ AWS authentication verified[/green]") - + # Initialize Bedrock client bedrock_client = BedrockClient(region) - + # Get available models - console.print(f"\n[yellow]Fetching available Claude models from {region}...[/yellow]") + console.print( + f"\n[yellow]Fetching available Claude models from " f"{region}...[/yellow]" + ) models = bedrock_client.list_claude_models() - + if not models: - console.print("[red]No Claude models found in the specified region.[/red]") + console.print("[red]No Claude models found in the specified " "region.[/red]") console.print("Please check your AWS permissions and region.") sys.exit(1) - + # Select model if non_interactive and models: selected_model = models[0] - console.print(f"[yellow]Using first available model: {selected_model['name']}[/yellow]") + console.print( + f"[yellow]Using first available model: " + f"{selected_model['name']}[/yellow]" + ) else: console.print("\n[bold]Available Claude models:[/bold]") for idx, model in enumerate(models, 1): console.print(f" {idx}. {model['name']} ({model['id']})") - + while True: try: choice = click.prompt("\nSelect a model", type=int) @@ -70,68 +90,70 @@ def setup(region, non_interactive): selected_model = models[choice - 1] break else: - console.print("[red]Invalid choice. Please try again.[/red]") + console.print("[red]Invalid choice. Please try " "again.[/red]") except (ValueError, KeyboardInterrupt): console.print("\n[yellow]Setup cancelled.[/yellow]") sys.exit(0) - + # Configure settings config_manager = ConfigManager() settings = { "CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": region, - "ANTHROPIC_MODEL": selected_model['arn'], + "ANTHROPIC_MODEL": selected_model["arn"], "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "4096", - "MAX_THINKING_TOKENS": "1024" + "MAX_THINKING_TOKENS": "1024", } - + config_manager.save_settings(settings) - + # Update .gitignore ensure_gitignore() - + console.print("\n[green]✓ Configuration saved successfully![/green]") console.print(f"\nModel: [cyan]{selected_model['name']}[/cyan]") console.print(f"Region: [cyan]{region}[/cyan]") - console.print(f"Settings file: [cyan]{config_manager.settings_path}[/cyan]") + console.print(f"Settings file: [cyan]{config_manager.settings_path}" "[/cyan]") console.print("\nClaude is now configured to use AWS Bedrock!") @cli.command() -def status(): +def status() -> None: """Show current Claude Bedrock configuration""" config_manager = ConfigManager() settings = config_manager.load_settings() - + if not settings: console.print("[yellow]No configuration found.[/yellow]") - console.print("Run 'claude-setup setup' to configure Claude for AWS Bedrock.") + console.print( + "Run 'claude-bedrock-setup setup' to configure Claude for " "AWS Bedrock." + ) return - - console.print(Panel.fit( - Text("Claude Bedrock Configuration", style="bold blue") - )) - + + console.print(Panel.fit(Text("Claude Bedrock Configuration", style="bold blue"))) + console.print("\n[bold]Current settings:[/bold]") for key, value in settings.items(): if key == "ANTHROPIC_MODEL": # Extract model ID from ARN - model_id = value.split('/')[-1] if '/' in value else value + model_id = value.split("/")[-1] if "/" in value else value console.print(f" {key}: [cyan]{model_id}[/cyan]") else: console.print(f" {key}: [cyan]{value}[/cyan]") - - console.print(f"\n[dim]Settings file: {config_manager.settings_path}[/dim]") + + console.print(f"\n[dim]Settings file: " f"{config_manager.settings_path}[/dim]") @cli.command() -@click.confirmation_option(prompt='Are you sure you want to reset the configuration?') -def reset(): +@click.confirmation_option( + prompt="Are you sure you want to reset the " "configuration?" +) +def reset() -> None: """Reset Claude Bedrock configuration""" config_manager = ConfigManager() config_manager.reset_settings() console.print("[green]✓ Configuration reset successfully.[/green]") -if __name__ == '__main__': - cli() \ No newline at end of file +if __name__ == "__main__": + cli() diff --git a/src/claude_setup/config_manager.py b/src/claude_setup/config_manager.py index be2aebd..1e2b669 100644 --- a/src/claude_setup/config_manager.py +++ b/src/claude_setup/config_manager.py @@ -1,45 +1,46 @@ +from __future__ import annotations + import json -import os from pathlib import Path from typing import Dict, Optional class ConfigManager: - def __init__(self): + def __init__(self) -> None: self.claude_dir = Path(".claude") self.settings_file = "settings.local.json" self.settings_path = self.claude_dir / self.settings_file - - def ensure_claude_directory(self): + + def ensure_claude_directory(self) -> None: """Ensure .claude directory exists""" self.claude_dir.mkdir(exist_ok=True) - - def save_settings(self, settings: Dict[str, str]): + + def save_settings(self, settings: Dict[str, str]) -> None: """Save settings to .claude/settings.local.json""" self.ensure_claude_directory() - + # Load existing settings if file exists existing_settings = self.load_settings() or {} - + # Update with new settings existing_settings.update(settings) - + # Write to file - with open(self.settings_path, 'w') as f: + with open(self.settings_path, "w") as f: json.dump(existing_settings, f, indent=2) - + def load_settings(self) -> Optional[Dict[str, str]]: """Load settings from .claude/settings.local.json""" if not self.settings_path.exists(): return None - + try: - with open(self.settings_path, 'r') as f: - return json.load(f) + with open(self.settings_path, "r") as f: + return json.load(f) # type: ignore[no-any-return] except (json.JSONDecodeError, IOError): return None - - def reset_settings(self): + + def reset_settings(self) -> None: """Remove the settings file""" if self.settings_path.exists(): - self.settings_path.unlink() \ No newline at end of file + self.settings_path.unlink() diff --git a/src/claude_setup/gitignore_manager.py b/src/claude_setup/gitignore_manager.py index 950cb5f..06297df 100644 --- a/src/claude_setup/gitignore_manager.py +++ b/src/claude_setup/gitignore_manager.py @@ -1,24 +1,26 @@ +from __future__ import annotations + from pathlib import Path -def ensure_gitignore(): +def ensure_gitignore() -> None: """Ensure .claude/settings.local.json is in .gitignore""" gitignore_path = Path(".gitignore") claude_settings_pattern = ".claude/settings.local.json" - + # Read existing .gitignore content if gitignore_path.exists(): - with open(gitignore_path, 'r') as f: + with open(gitignore_path, "r") as f: content = f.read() - lines = content.strip().split('\n') if content.strip() else [] + lines = content.strip().split("\n") if content.strip() else [] else: lines = [] - + # Check if pattern already exists if claude_settings_pattern not in lines: # Add the pattern lines.append(claude_settings_pattern) - + # Write back to .gitignore - with open(gitignore_path, 'w') as f: - f.write('\n'.join(lines) + '\n') \ No newline at end of file + with open(gitignore_path, "w") as f: + f.write("\n".join(lines) + "\n") diff --git a/tests/__init__.py b/tests/__init__.py index 82cc472..93be492 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# Test package for claude-bedrock-setup CLI \ No newline at end of file +# Test package for claude-bedrock-setup CLI diff --git a/tests/conftest.py b/tests/conftest.py index ecc269a..e7bc02c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Pytest configuration and shared fixtures for claude-bedrock-setup tests.""" -import json import tempfile from pathlib import Path from unittest.mock import MagicMock @@ -21,9 +20,12 @@ def mock_settings(): return { "CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": "us-west-2", - "ANTHROPIC_MODEL": "arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-sonnet-20240229-v1:0", + "ANTHROPIC_MODEL": ( + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/" + "anthropic.claude-3-sonnet-20240229-v1:0" + ), "CLAUDE_CODE_MAX_OUTPUT_TOKENS": "4096", - "MAX_THINKING_TOKENS": "1024" + "MAX_THINKING_TOKENS": "1024", } @@ -32,23 +34,32 @@ def mock_claude_models(): """Sample Claude models response for testing.""" return [ { - 'id': 'anthropic.claude-3-sonnet-20240229-v1:0', - 'name': 'Claude 3 Sonnet', - 'arn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-sonnet-20240229-v1:0', - 'status': 'ACTIVE' + "id": "anthropic.claude-3-sonnet-20240229-v1:0", + "name": "Claude 3 Sonnet", + "arn": ( + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/" + "anthropic.claude-3-sonnet-20240229-v1:0" + ), + "status": "ACTIVE", }, { - 'id': 'anthropic.claude-3-haiku-20240307-v1:0', - 'name': 'Claude 3 Haiku', - 'arn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-haiku-20240307-v1:0', - 'status': 'ACTIVE' + "id": "anthropic.claude-3-haiku-20240307-v1:0", + "name": "Claude 3 Haiku", + "arn": ( + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/" + "anthropic.claude-3-haiku-20240307-v1:0" + ), + "status": "ACTIVE", }, { - 'id': 'anthropic.claude-3-opus-20240229-v1:0', - 'name': 'Claude 3 Opus', - 'arn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-opus-20240229-v1:0', - 'status': 'ACTIVE' - } + "id": "anthropic.claude-3-opus-20240229-v1:0", + "name": "Claude 3 Opus", + "arn": ( + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/" + "anthropic.claude-3-opus-20240229-v1:0" + ), + "status": "ACTIVE", + }, ] @@ -56,31 +67,46 @@ def mock_claude_models(): def mock_aws_response(): """Mock AWS CLI response for list-inference-profiles.""" return { - 'inferenceProfileSummaries': [ + "inferenceProfileSummaries": [ { - 'inferenceProfileId': 'anthropic.claude-3-sonnet-20240229-v1:0', - 'inferenceProfileName': 'Claude 3 Sonnet', - 'inferenceProfileArn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-sonnet-20240229-v1:0', - 'status': 'ACTIVE' + "inferenceProfileId": ("anthropic.claude-3-sonnet-20240229-v1:0"), + "inferenceProfileName": "Claude 3 Sonnet", + "inferenceProfileArn": ( + "arn:aws:bedrock:us-west-2:123456789012:" + "inference-profile/" + "anthropic.claude-3-sonnet-20240229-v1:0" + ), + "status": "ACTIVE", }, { - 'inferenceProfileId': 'anthropic.claude-3-haiku-20240307-v1:0', - 'inferenceProfileName': 'Claude 3 Haiku', - 'inferenceProfileArn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-haiku-20240307-v1:0', - 'status': 'ACTIVE' + "inferenceProfileId": ("anthropic.claude-3-haiku-20240307-v1:0"), + "inferenceProfileName": "Claude 3 Haiku", + "inferenceProfileArn": ( + "arn:aws:bedrock:us-west-2:123456789012:" + "inference-profile/" + "anthropic.claude-3-haiku-20240307-v1:0" + ), + "status": "ACTIVE", }, { - 'inferenceProfileId': 'anthropic.claude-3-opus-20240229-v1:0', - 'inferenceProfileName': 'Claude 3 Opus', - 'inferenceProfileArn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-opus-20240229-v1:0', - 'status': 'ACTIVE' + "inferenceProfileId": ("anthropic.claude-3-opus-20240229-v1:0"), + "inferenceProfileName": "Claude 3 Opus", + "inferenceProfileArn": ( + "arn:aws:bedrock:us-west-2:123456789012:" + "inference-profile/" + "anthropic.claude-3-opus-20240229-v1:0" + ), + "status": "ACTIVE", }, { - 'inferenceProfileId': 'meta.llama3-8b-instruct-v1:0', - 'inferenceProfileName': 'Llama 3 8B', - 'inferenceProfileArn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/meta.llama3-8b-instruct-v1:0', - 'status': 'ACTIVE' - } + "inferenceProfileId": "meta.llama3-8b-instruct-v1:0", + "inferenceProfileName": "Llama 3 8B", + "inferenceProfileArn": ( + "arn:aws:bedrock:us-west-2:123456789012:" + "inference-profile/meta.llama3-8b-instruct-v1:0" + ), + "status": "ACTIVE", + }, ] } @@ -88,6 +114,7 @@ def mock_aws_response(): @pytest.fixture def mock_subprocess_run(): """Create a mock subprocess.run with configurable behavior.""" + def _mock_run(returncode=0, stdout="", stderr="", side_effect=None): mock = MagicMock() mock.returncode = returncode @@ -96,4 +123,5 @@ def _mock_run(returncode=0, stdout="", stderr="", side_effect=None): if side_effect: mock.side_effect = side_effect return mock - return _mock_run \ No newline at end of file + + return _mock_run diff --git a/tests/test_auth_checker.py b/tests/test_auth_checker.py index 52472b4..50db7ba 100644 --- a/tests/test_auth_checker.py +++ b/tests/test_auth_checker.py @@ -3,119 +3,116 @@ import subprocess from unittest.mock import patch, MagicMock -import pytest - from claude_setup.auth_checker import check_aws_auth class TestCheckAWSAuth: """Test cases for check_aws_auth function.""" - @patch('claude_setup.auth_checker.subprocess.run') + @patch("claude_setup.auth_checker.subprocess.run") def test_check_aws_auth_success(self, mock_run): """Test successful AWS authentication check.""" # Arrange mock_run.return_value = MagicMock(returncode=0) - + # Act result = check_aws_auth() - + # Assert assert result is True mock_run.assert_called_once_with( - ['aws', 'sts', 'get-caller-identity'], + ["aws", "sts", "get-caller-identity"], capture_output=True, text=True, - check=True + check=True, ) - @patch('claude_setup.auth_checker.subprocess.run') + @patch("claude_setup.auth_checker.subprocess.run") def test_check_aws_auth_called_process_error(self, mock_run): """Test AWS authentication check with CalledProcessError.""" # Arrange - mock_run.side_effect = subprocess.CalledProcessError(1, 'aws') - + mock_run.side_effect = subprocess.CalledProcessError(1, "aws") + # Act result = check_aws_auth() - + # Assert assert result is False mock_run.assert_called_once_with( - ['aws', 'sts', 'get-caller-identity'], + ["aws", "sts", "get-caller-identity"], capture_output=True, text=True, - check=True + check=True, ) - @patch('claude_setup.auth_checker.subprocess.run') + @patch("claude_setup.auth_checker.subprocess.run") def test_check_aws_auth_file_not_found_error(self, mock_run): """Test AWS authentication check when AWS CLI is not installed.""" # Arrange mock_run.side_effect = FileNotFoundError("aws command not found") - + # Act result = check_aws_auth() - + # Assert assert result is False mock_run.assert_called_once_with( - ['aws', 'sts', 'get-caller-identity'], + ["aws", "sts", "get-caller-identity"], capture_output=True, text=True, - check=True + check=True, ) - @patch('claude_setup.auth_checker.subprocess.run') + @patch("claude_setup.auth_checker.subprocess.run") def test_check_aws_auth_unexpected_exception(self, mock_run): """Test AWS authentication check with unexpected exception.""" # Arrange mock_run.side_effect = RuntimeError("Unexpected error") - + # Act result = check_aws_auth() - + # Assert assert result is False mock_run.assert_called_once_with( - ['aws', 'sts', 'get-caller-identity'], + ["aws", "sts", "get-caller-identity"], capture_output=True, text=True, - check=True + check=True, ) - @patch('claude_setup.auth_checker.subprocess.run') + @patch("claude_setup.auth_checker.subprocess.run") def test_check_aws_auth_permission_error(self, mock_run): """Test AWS authentication check with permission error.""" # Arrange mock_run.side_effect = PermissionError("Permission denied") - + # Act result = check_aws_auth() - + # Assert assert result is False mock_run.assert_called_once_with( - ['aws', 'sts', 'get-caller-identity'], + ["aws", "sts", "get-caller-identity"], capture_output=True, text=True, - check=True + check=True, ) - @patch('claude_setup.auth_checker.subprocess.run') + @patch("claude_setup.auth_checker.subprocess.run") def test_check_aws_auth_timeout_error(self, mock_run): """Test AWS authentication check with timeout error.""" # Arrange - mock_run.side_effect = subprocess.TimeoutExpired('aws', 30) - + mock_run.side_effect = subprocess.TimeoutExpired("aws", 30) + # Act result = check_aws_auth() - + # Assert assert result is False mock_run.assert_called_once_with( - ['aws', 'sts', 'get-caller-identity'], + ["aws", "sts", "get-caller-identity"], capture_output=True, text=True, - check=True + check=True, ) - diff --git a/tests/test_aws_client.py b/tests/test_aws_client.py index 66b9a09..dcc7558 100644 --- a/tests/test_aws_client.py +++ b/tests/test_aws_client.py @@ -15,257 +15,290 @@ class TestBedrockClient: def test_init_default_region(self): """Test BedrockClient initialization with default region.""" client = BedrockClient() - assert client.region == 'us-west-2' + assert client.region == "us-west-2" def test_init_custom_region(self): """Test BedrockClient initialization with custom region.""" - region = 'us-east-1' + region = "us-east-1" client = BedrockClient(region) assert client.region == region - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_success(self, mock_run, mock_aws_response): """Test successful listing of Claude models.""" # Arrange - client = BedrockClient('us-west-2') + client = BedrockClient("us-west-2") mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps(mock_aws_response) + returncode=0, stdout=json.dumps(mock_aws_response) ) - + # Act result = client.list_claude_models() - + # Assert assert len(result) == 3 # Only Claude models, not Llama - assert all('anthropic.claude' in model['id'] for model in result) - + assert all("anthropic.claude" in model["id"] for model in result) + # Check first model details - assert result[0]['id'] == 'anthropic.claude-3-haiku-20240307-v1:0' - assert result[0]['name'] == 'Claude 3 Haiku' - assert result[0]['arn'] == 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-haiku-20240307-v1:0' - assert result[0]['status'] == 'ACTIVE' - + assert result[0]["id"] == "anthropic.claude-3-haiku-20240307-v1:0" + assert result[0]["name"] == "Claude 3 Haiku" + expected_arn = ( + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/" + "anthropic.claude-3-haiku-20240307-v1:0" + ) + assert result[0]["arn"] == expected_arn + assert result[0]["status"] == "ACTIVE" + # Verify models are sorted by name - model_names = [model['name'] for model in result] + model_names = [model["name"] for model in result] assert model_names == sorted(model_names) - + mock_run.assert_called_once_with( - ['aws', 'bedrock', 'list-inference-profiles', '--region', 'us-west-2'], + [ + "aws", + "bedrock", + "list-inference-profiles", + "--region", + "us-west-2", + ], capture_output=True, text=True, - check=True + check=True, ) - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_empty_response(self, mock_run): """Test listing Claude models with empty response.""" # Arrange client = BedrockClient() mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps({'inferenceProfileSummaries': []}) + returncode=0, stdout=json.dumps({"inferenceProfileSummaries": []}) ) - + # Act result = client.list_claude_models() - + # Assert assert result == [] mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_no_claude_models(self, mock_run): """Test listing when no Claude models are available.""" # Arrange client = BedrockClient() non_claude_response = { - 'inferenceProfileSummaries': [ + "inferenceProfileSummaries": [ { - 'inferenceProfileId': 'meta.llama3-8b-instruct-v1:0', - 'inferenceProfileName': 'Llama 3 8B', - 'inferenceProfileArn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/meta.llama3-8b-instruct-v1:0', - 'status': 'ACTIVE' + "inferenceProfileId": "meta.llama3-8b-instruct-v1:0", + "inferenceProfileName": "Llama 3 8B", + "inferenceProfileArn": ( + "arn:aws:bedrock:us-west-2:123456789012:" + "inference-profile/meta.llama3-8b-instruct-v1:0" + ), + "status": "ACTIVE", } ] } mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps(non_claude_response) + returncode=0, stdout=json.dumps(non_claude_response) ) - + # Act result = client.list_claude_models() - + # Assert assert result == [] mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_missing_profile_name(self, mock_run): """Test listing Claude models when profile name is missing.""" # Arrange client = BedrockClient() response_without_name = { - 'inferenceProfileSummaries': [ + "inferenceProfileSummaries": [ { - 'inferenceProfileId': 'anthropic.claude-3-sonnet-20240229-v1:0', - 'inferenceProfileArn': 'arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-sonnet-20240229-v1:0', - 'status': 'ACTIVE' + "inferenceProfileId": ("anthropic.claude-3-sonnet-20240229-v1:0"), + "inferenceProfileArn": ( + "arn:aws:bedrock:us-west-2:123456789012:" + "inference-profile/" + "anthropic.claude-3-sonnet-20240229-v1:0" + ), + "status": "ACTIVE", } ] } mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps(response_without_name) + returncode=0, stdout=json.dumps(response_without_name) ) - + # Act result = client.list_claude_models() - + # Assert assert len(result) == 1 # Should use the last part of profile ID as name - assert result[0]['name'] == 'anthropic.claude-3-sonnet-20240229-v1:0' + assert result[0]["name"] == "anthropic.claude-3-sonnet-20240229-v1:0" mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_access_denied_error(self, mock_run): """Test listing Claude models with AccessDeniedException.""" # Arrange client = BedrockClient() error = subprocess.CalledProcessError( - 1, 'aws', stderr='AccessDeniedException: User not authorized' + 1, "aws", stderr="AccessDeniedException: User not authorized" ) mock_run.side_effect = error - + # Act & Assert - with pytest.raises(Exception, match="Access denied. Please check your AWS permissions for Amazon Bedrock."): + with pytest.raises( + Exception, + match=( + "Access denied. Please check your AWS " + "permissions for Amazon Bedrock." + ), + ): client.list_claude_models() - + mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_not_authorized_error(self, mock_run): """Test listing Claude models with authentication error.""" # Arrange client = BedrockClient() error = subprocess.CalledProcessError( - 1, 'aws', stderr='The security token included in the request is invalid. not authorized' + 1, + "aws", + stderr=( + "The security token included in the request is invalid. " + "not authorized" + ), ) mock_run.side_effect = error - + # Act & Assert - with pytest.raises(Exception, match="Not authenticated with AWS. Please run 'aws configure' or set up your AWS credentials."): + with pytest.raises( + Exception, + match=( + "Not authenticated with AWS. Please run 'aws configure' " + "or set up your AWS credentials." + ), + ): client.list_claude_models() - + mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_generic_called_process_error(self, mock_run): """Test listing Claude models with generic CalledProcessError.""" # Arrange client = BedrockClient() error_message = "Some other AWS CLI error" - error = subprocess.CalledProcessError(1, 'aws', stderr=error_message) + error = subprocess.CalledProcessError(1, "aws", stderr=error_message) mock_run.side_effect = error - + # Act & Assert with pytest.raises(Exception, match=f"Error listing models: {error_message}"): client.list_claude_models() - + mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_json_decode_error(self, mock_run): """Test listing Claude models with invalid JSON response.""" # Arrange client = BedrockClient() - mock_run.return_value = MagicMock( - returncode=0, - stdout="invalid json" - ) - + mock_run.return_value = MagicMock(returncode=0, stdout="invalid json") + # Act & Assert with pytest.raises(Exception, match="Unexpected error:"): client.list_claude_models() - + mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_unexpected_error(self, mock_run): """Test listing Claude models with unexpected error.""" # Arrange client = BedrockClient() mock_run.side_effect = RuntimeError("Unexpected runtime error") - + # Act & Assert - with pytest.raises(Exception, match="Unexpected error: Unexpected runtime error"): + with pytest.raises( + Exception, match="Unexpected error: Unexpected runtime error" + ): client.list_claude_models() - + mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_custom_region(self, mock_run, mock_aws_response): """Test listing Claude models with custom region.""" # Arrange - custom_region = 'eu-west-1' + custom_region = "eu-west-1" client = BedrockClient(custom_region) mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps(mock_aws_response) + returncode=0, stdout=json.dumps(mock_aws_response) ) - + # Act result = client.list_claude_models() - + # Assert assert len(result) == 3 mock_run.assert_called_once_with( - ['aws', 'bedrock', 'list-inference-profiles', '--region', custom_region], + [ + "aws", + "bedrock", + "list-inference-profiles", + "--region", + custom_region, + ], capture_output=True, text=True, - check=True + check=True, ) - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_partial_data(self, mock_run): """Test listing Claude models with partial data in response.""" # Arrange client = BedrockClient() partial_response = { - 'inferenceProfileSummaries': [ + "inferenceProfileSummaries": [ { - 'inferenceProfileId': 'anthropic.claude-3-sonnet-20240229-v1:0', + "inferenceProfileId": ("anthropic.claude-3-sonnet-20240229-v1:0"), # Missing other fields } ] } mock_run.return_value = MagicMock( - returncode=0, - stdout=json.dumps(partial_response) + returncode=0, stdout=json.dumps(partial_response) ) - + # Act result = client.list_claude_models() - + # Assert assert len(result) == 1 - assert result[0]['id'] == 'anthropic.claude-3-sonnet-20240229-v1:0' - assert result[0]['name'] == 'anthropic.claude-3-sonnet-20240229-v1:0' - assert result[0]['arn'] == '' - assert result[0]['status'] == 'ACTIVE' # Default value + assert result[0]["id"] == "anthropic.claude-3-sonnet-20240229-v1:0" + assert result[0]["name"] == "anthropic.claude-3-sonnet-20240229-v1:0" + assert result[0]["arn"] == "" + assert result[0]["status"] == "ACTIVE" # Default value mock_run.assert_called_once() - @patch('claude_setup.aws_client.subprocess.run') + @patch("claude_setup.aws_client.subprocess.run") def test_list_claude_models_file_not_found_error(self, mock_run): """Test listing Claude models when AWS CLI is not installed.""" # Arrange client = BedrockClient() mock_run.side_effect = FileNotFoundError("aws command not found") - + # Act & Assert with pytest.raises(Exception, match="Unexpected error: aws command not found"): client.list_claude_models() - - mock_run.assert_called_once() \ No newline at end of file + + mock_run.assert_called_once() diff --git a/tests/test_cli.py b/tests/test_cli.py index 10abc80..4382d13 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,13 +1,16 @@ """Tests for the CLI module.""" -import json from unittest.mock import patch, MagicMock +import sys import pytest from click.testing import CliRunner +# Import CLI module to ensure it's in sys.modules +import claude_setup.cli + +# Then import the commands we need from claude_setup.cli import cli, setup, status, reset -from claude_setup.config_manager import ConfigManager class TestCLI: @@ -15,17 +18,17 @@ class TestCLI: def setup_method(self): """Set up test fixtures.""" - self.runner = CliRunner() + self.runner = CliRunner(env={"NO_COLOR": "1"}) def test_cli_version(self): """Test CLI version option.""" - result = self.runner.invoke(cli, ['--version']) + result = self.runner.invoke(cli, ["--version"]) assert result.exit_code == 0 assert "claude-bedrock-setup, version 0.1.0" in result.output def test_cli_help(self): """Test CLI help.""" - result = self.runner.invoke(cli, ['--help']) + result = self.runner.invoke(cli, ["--help"]) assert result.exit_code == 0 assert "Claude Bedrock Setup CLI" in result.output assert "setup" in result.output @@ -38,13 +41,20 @@ class TestSetupCommand: def setup_method(self): """Set up test fixtures.""" - self.runner = CliRunner() - - @patch('claude_setup.cli.ensure_gitignore') - @patch('claude_setup.cli.ConfigManager') - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') - def test_setup_success_non_interactive(self, mock_auth, mock_client_class, mock_config_class, mock_gitignore, mock_claude_models): + self.runner = CliRunner(env={"NO_COLOR": "1"}) + + @patch.object(sys.modules["claude_setup.cli"], "ensure_gitignore") + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + def test_setup_success_non_interactive( + self, + mock_auth, + mock_client_class, + mock_config_class, + mock_gitignore, + mock_claude_models, + ): """Test successful setup in non-interactive mode.""" # Arrange mock_auth.return_value = True @@ -53,25 +63,32 @@ def test_setup_success_non_interactive(self, mock_auth, mock_client_class, mock_ mock_client_class.return_value = mock_client mock_config = MagicMock() mock_config_class.return_value = mock_config - + # Act - result = self.runner.invoke(setup, ['--non-interactive']) - + result = self.runner.invoke(setup, ["--non-interactive"]) + # Assert assert result.exit_code == 0 assert "AWS authentication verified" in result.output assert "Configuration saved successfully!" in result.output mock_auth.assert_called_once() - mock_client_class.assert_called_once_with('us-west-2') + mock_client_class.assert_called_once_with("us-west-2") mock_client.list_claude_models.assert_called_once() mock_config.save_settings.assert_called_once() mock_gitignore.assert_called_once() - @patch('claude_setup.cli.ensure_gitignore') - @patch('claude_setup.cli.ConfigManager') - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') - def test_setup_success_interactive(self, mock_auth, mock_client_class, mock_config_class, mock_gitignore, mock_claude_models): + @patch.object(sys.modules["claude_setup.cli"], "ensure_gitignore") + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + def test_setup_success_interactive( + self, + mock_auth, + mock_client_class, + mock_config_class, + mock_gitignore, + mock_claude_models, + ): """Test successful setup in interactive mode.""" # Arrange mock_auth.return_value = True @@ -80,10 +97,10 @@ def test_setup_success_interactive(self, mock_auth, mock_client_class, mock_conf mock_client_class.return_value = mock_client mock_config = MagicMock() mock_config_class.return_value = mock_config - + # Act - simulate user selecting model 2 - result = self.runner.invoke(setup, input='2\n') - + result = self.runner.invoke(setup, input="2\n") + # Assert assert result.exit_code == 0 assert "Available Claude models:" in result.output @@ -95,23 +112,23 @@ def test_setup_success_interactive(self, mock_auth, mock_client_class, mock_conf mock_config.save_settings.assert_called_once() mock_gitignore.assert_called_once() - @patch('claude_setup.cli.check_aws_auth') + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") def test_setup_auth_failure(self, mock_auth): """Test setup when AWS authentication fails.""" # Arrange mock_auth.return_value = False - + # Act result = self.runner.invoke(setup) - + # Assert assert result.exit_code == 1 assert "Not authenticated with AWS" in result.output assert "aws configure" in result.output mock_auth.assert_called_once() - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") def test_setup_no_models_found(self, mock_auth, mock_client_class): """Test setup when no Claude models are found.""" # Arrange @@ -119,21 +136,28 @@ def test_setup_no_models_found(self, mock_auth, mock_client_class): mock_client = MagicMock() mock_client.list_claude_models.return_value = [] mock_client_class.return_value = mock_client - + # Act result = self.runner.invoke(setup) - + # Assert assert result.exit_code == 1 assert "No Claude models found" in result.output mock_auth.assert_called_once() mock_client.list_claude_models.assert_called_once() - @patch('claude_setup.cli.ensure_gitignore') - @patch('claude_setup.cli.ConfigManager') - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') - def test_setup_custom_region(self, mock_auth, mock_client_class, mock_config_class, mock_gitignore, mock_claude_models): + @patch.object(sys.modules["claude_setup.cli"], "ensure_gitignore") + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + def test_setup_custom_region( + self, + mock_auth, + mock_client_class, + mock_config_class, + mock_gitignore, + mock_claude_models, + ): """Test setup with custom region.""" # Arrange mock_auth.return_value = True @@ -142,54 +166,64 @@ def test_setup_custom_region(self, mock_auth, mock_client_class, mock_config_cla mock_client_class.return_value = mock_client mock_config = MagicMock() mock_config_class.return_value = mock_config - + # Act - result = self.runner.invoke(setup, ['--region', 'eu-west-1', '--non-interactive']) - + result = self.runner.invoke( + setup, ["--region", "eu-west-1", "--non-interactive"] + ) + # Assert assert result.exit_code == 0 - mock_client_class.assert_called_once_with('eu-west-1') - + mock_client_class.assert_called_once_with("eu-west-1") + # Check that settings include custom region call_args = mock_config.save_settings.call_args[0][0] - assert call_args['AWS_REGION'] == 'eu-west-1' + assert call_args["AWS_REGION"] == "eu-west-1" - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') - def test_setup_interactive_invalid_choice(self, mock_auth, mock_client_class, mock_claude_models): + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + def test_setup_interactive_invalid_choice( + self, mock_auth, mock_client_class, mock_claude_models + ): """Test interactive setup with invalid choice.""" # Arrange mock_auth.return_value = True mock_client = MagicMock() mock_client.list_claude_models.return_value = mock_claude_models mock_client_class.return_value = mock_client - + # Act - simulate invalid choice then valid choice - result = self.runner.invoke(setup, input='5\n2\n') - + result = self.runner.invoke(setup, input="5\n2\n") + # Assert assert result.exit_code == 0 assert "Invalid choice. Please try again." in result.output - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') - def test_setup_interactive_keyboard_interrupt(self, mock_auth, mock_client_class, mock_claude_models): + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + def test_setup_interactive_keyboard_interrupt( + self, mock_auth, mock_client_class, mock_claude_models + ): """Test interactive setup with keyboard interrupt.""" # Arrange mock_auth.return_value = True mock_client = MagicMock() mock_client.list_claude_models.return_value = mock_claude_models mock_client_class.return_value = mock_client - + # Act - simulate invalid input that causes ValueError, then abort - # This simulates a user cancelling by giving invalid input then aborting - result = self.runner.invoke(setup, input='invalid\ninvalid\n') - + # This simulates a user cancelling by giving invalid input + # then aborting + result = self.runner.invoke(setup, input="invalid\ninvalid\n") + # Assert - the setup should handle invalid input and show abort message - assert "Error: 'invalid' is not a valid integer." in result.output and "Aborted!" in result.output + assert ( + "Error: 'invalid' is not a valid integer." in result.output + and "Aborted!" in result.output + ) - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") def test_setup_bedrock_client_exception(self, mock_auth, mock_client_class): """Test setup when BedrockClient raises exception.""" # Arrange @@ -197,16 +231,23 @@ def test_setup_bedrock_client_exception(self, mock_auth, mock_client_class): mock_client = MagicMock() mock_client.list_claude_models.side_effect = Exception("AWS API Error") mock_client_class.return_value = mock_client - + # Act & Assert with pytest.raises(Exception, match="AWS API Error"): self.runner.invoke(setup, catch_exceptions=False) - @patch('claude_setup.cli.ensure_gitignore') - @patch('claude_setup.cli.ConfigManager') - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') - def test_setup_config_manager_exception(self, mock_auth, mock_client_class, mock_config_class, mock_gitignore, mock_claude_models): + @patch.object(sys.modules["claude_setup.cli"], "ensure_gitignore") + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + def test_setup_config_manager_exception( + self, + mock_auth, + mock_client_class, + mock_config_class, + mock_gitignore, + mock_claude_models, + ): """Test setup when ConfigManager raises exception.""" # Arrange mock_auth.return_value = True @@ -216,16 +257,27 @@ def test_setup_config_manager_exception(self, mock_auth, mock_client_class, mock mock_config = MagicMock() mock_config.save_settings.side_effect = Exception("Config save error") mock_config_class.return_value = mock_config - + # Act & Assert with pytest.raises(Exception, match="Config save error"): - self.runner.invoke(setup, ['--non-interactive'], catch_exceptions=False) - - @patch('claude_setup.cli.ensure_gitignore', side_effect=Exception("Gitignore error")) - @patch('claude_setup.cli.ConfigManager') - @patch('claude_setup.cli.BedrockClient') - @patch('claude_setup.cli.check_aws_auth') - def test_setup_gitignore_exception(self, mock_auth, mock_client_class, mock_config_class, mock_gitignore, mock_claude_models): + self.runner.invoke(setup, ["--non-interactive"], catch_exceptions=False) + + @patch.object( + sys.modules["claude_setup.cli"], + "ensure_gitignore", + side_effect=Exception("Gitignore error"), + ) + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") + @patch.object(sys.modules["claude_setup.cli"], "BedrockClient") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + def test_setup_gitignore_exception( + self, + mock_auth, + mock_client_class, + mock_config_class, + mock_gitignore, + mock_claude_models, + ): """Test setup when ensure_gitignore raises exception.""" # Arrange mock_auth.return_value = True @@ -234,10 +286,10 @@ def test_setup_gitignore_exception(self, mock_auth, mock_client_class, mock_conf mock_client_class.return_value = mock_client mock_config = MagicMock() mock_config_class.return_value = mock_config - + # Act & Assert with pytest.raises(Exception, match="Gitignore error"): - self.runner.invoke(setup, ['--non-interactive'], catch_exceptions=False) + self.runner.invoke(setup, ["--non-interactive"], catch_exceptions=False) class TestStatusCommand: @@ -245,26 +297,26 @@ class TestStatusCommand: def setup_method(self): """Set up test fixtures.""" - self.runner = CliRunner() + self.runner = CliRunner(env={"NO_COLOR": "1"}) - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_status_no_configuration(self, mock_config_class): """Test status when no configuration exists.""" # Arrange mock_config = MagicMock() mock_config.load_settings.return_value = None mock_config_class.return_value = mock_config - + # Act result = self.runner.invoke(status) - + # Assert assert result.exit_code == 0 assert "No configuration found." in result.output assert "Run 'claude-bedrock-setup setup'" in result.output mock_config.load_settings.assert_called_once() - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_status_with_configuration(self, mock_config_class, mock_settings): """Test status with existing configuration.""" # Arrange @@ -272,64 +324,70 @@ def test_status_with_configuration(self, mock_config_class, mock_settings): mock_config.load_settings.return_value = mock_settings mock_config.settings_path = "/test/.claude/settings.local.json" mock_config_class.return_value = mock_config - + # Act result = self.runner.invoke(status) - + # Assert assert result.exit_code == 0 assert "Claude Bedrock Configuration" in result.output assert "Current settings:" in result.output assert "CLAUDE_CODE_USE_BEDROCK: 1" in result.output assert "AWS_REGION: us-west-2" in result.output - assert "anthropic.claude-3-sonnet-20240229-v1:0" in result.output # Extracted from ARN + assert ( + "anthropic.claude-3-sonnet-20240229-v1:0" in result.output + ) # Extracted from ARN assert "Settings file: /test/.claude/settings.local.json" in result.output mock_config.load_settings.assert_called_once() - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_status_arn_extraction(self, mock_config_class): """Test status with ARN extraction for ANTHROPIC_MODEL.""" # Arrange settings_with_arn = { - "ANTHROPIC_MODEL": "arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-3-haiku-20240307-v1:0" + "ANTHROPIC_MODEL": ( + "arn:aws:bedrock:us-west-2:123456789012:" + "inference-profile/" + "anthropic.claude-3-haiku-20240307-v1:0" + ) } mock_config = MagicMock() mock_config.load_settings.return_value = settings_with_arn mock_config_class.return_value = mock_config - + # Act result = self.runner.invoke(status) - + # Assert assert result.exit_code == 0 - assert "ANTHROPIC_MODEL: anthropic.claude-3-haiku-20240307-v1:0" in result.output + assert ( + "ANTHROPIC_MODEL: anthropic.claude-3-haiku-20240307-v1:0" in result.output + ) - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_status_simple_model_id(self, mock_config_class): """Test status with simple model ID (no ARN).""" # Arrange - settings_simple = { - "ANTHROPIC_MODEL": "claude-3-sonnet" - } + settings_simple = {"ANTHROPIC_MODEL": "claude-3-sonnet"} mock_config = MagicMock() mock_config.load_settings.return_value = settings_simple mock_config_class.return_value = mock_config - + # Act result = self.runner.invoke(status) - + # Assert assert result.exit_code == 0 assert "ANTHROPIC_MODEL: claude-3-sonnet" in result.output - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_status_config_manager_exception(self, mock_config_class): """Test status when ConfigManager raises exception.""" # Arrange mock_config = MagicMock() mock_config.load_settings.side_effect = Exception("Config load error") mock_config_class.return_value = mock_config - + # Act & Assert with pytest.raises(Exception, match="Config load error"): self.runner.invoke(status, catch_exceptions=False) @@ -340,65 +398,65 @@ class TestResetCommand: def setup_method(self): """Set up test fixtures.""" - self.runner = CliRunner() + self.runner = CliRunner(env={"NO_COLOR": "1"}) - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_reset_confirmed(self, mock_config_class): """Test reset when user confirms.""" # Arrange mock_config = MagicMock() mock_config_class.return_value = mock_config - + # Act - simulate user confirming with 'y' - result = self.runner.invoke(reset, input='y\n') - + result = self.runner.invoke(reset, input="y\n") + # Assert assert result.exit_code == 0 assert "Configuration reset successfully." in result.output mock_config.reset_settings.assert_called_once() - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_reset_cancelled(self, mock_config_class): """Test reset when user cancels.""" # Arrange mock_config = MagicMock() mock_config_class.return_value = mock_config - + # Act - simulate user canceling with 'n' - result = self.runner.invoke(reset, input='n\n') - + result = self.runner.invoke(reset, input="n\n") + # Assert assert result.exit_code == 1 # Click confirmation returns 1 when cancelled mock_config.reset_settings.assert_not_called() - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_reset_config_manager_exception(self, mock_config_class): """Test reset when ConfigManager raises exception.""" # Arrange mock_config = MagicMock() mock_config.reset_settings.side_effect = Exception("Reset error") mock_config_class.return_value = mock_config - + # Act & Assert with pytest.raises(Exception, match="Reset error"): - self.runner.invoke(reset, input='y\n', catch_exceptions=False) + self.runner.invoke(reset, input="y\n", catch_exceptions=False) def test_reset_help(self): """Test reset command help.""" - result = self.runner.invoke(reset, ['--help']) + result = self.runner.invoke(reset, ["--help"]) assert result.exit_code == 0 assert "Reset Claude Bedrock configuration" in result.output - @patch('claude_setup.cli.ConfigManager') + @patch.object(sys.modules["claude_setup.cli"], "ConfigManager") def test_reset_keyboard_interrupt(self, mock_config_class): """Test reset with keyboard interrupt during confirmation.""" # Arrange mock_config = MagicMock() mock_config_class.return_value = mock_config - + # Act - simulate user aborting by not providing input - result = self.runner.invoke(reset, input='') - + result = self.runner.invoke(reset, input="") + # Assert - should abort when no input is provided assert result.exit_code == 1 # Aborted mock_config.reset_settings.assert_not_called() @@ -409,11 +467,11 @@ class TestCLIIntegration: def setup_method(self): """Set up test fixtures.""" - self.runner = CliRunner() + self.runner = CliRunner(env={"NO_COLOR": "1"}) def test_cli_help_shows_all_commands(self): """Test that CLI help shows all available commands.""" - result = self.runner.invoke(cli, ['--help']) + result = self.runner.invoke(cli, ["--help"]) assert result.exit_code == 0 assert "setup" in result.output assert "status" in result.output @@ -422,24 +480,24 @@ def test_cli_help_shows_all_commands(self): def test_individual_command_help(self): """Test help for individual commands.""" # Test setup help - result = self.runner.invoke(setup, ['--help']) + result = self.runner.invoke(setup, ["--help"]) assert result.exit_code == 0 assert "Set up Claude to use AWS Bedrock" in result.output assert "--region" in result.output assert "--non-interactive" in result.output # Test status help - result = self.runner.invoke(status, ['--help']) + result = self.runner.invoke(status, ["--help"]) assert result.exit_code == 0 assert "Show current Claude Bedrock configuration" in result.output # Test reset help - result = self.runner.invoke(reset, ['--help']) + result = self.runner.invoke(reset, ["--help"]) assert result.exit_code == 0 assert "Reset Claude Bedrock configuration" in result.output def test_invalid_command(self): """Test CLI with invalid command.""" - result = self.runner.invoke(cli, ['invalid-command']) + result = self.runner.invoke(cli, ["invalid-command"]) assert result.exit_code != 0 - assert "No such command" in result.output \ No newline at end of file + assert "No such command" in result.output diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py index 3fb71e2..96853c7 100644 --- a/tests/test_config_manager.py +++ b/tests/test_config_manager.py @@ -1,9 +1,7 @@ """Tests for the config_manager module.""" -import json -import os from pathlib import Path -from unittest.mock import patch, mock_open, MagicMock +from unittest.mock import patch, mock_open import pytest @@ -16,260 +14,280 @@ class TestConfigManager: def test_init(self): """Test ConfigManager initialization.""" config_manager = ConfigManager() - + assert config_manager.claude_dir == Path(".claude") assert config_manager.settings_file == "settings.local.json" assert config_manager.settings_path == Path(".claude/settings.local.json") - @patch('claude_setup.config_manager.Path.mkdir') + @patch("claude_setup.config_manager.Path.mkdir") def test_ensure_claude_directory(self, mock_mkdir): """Test ensuring .claude directory exists.""" # Arrange config_manager = ConfigManager() - + # Act config_manager.ensure_claude_directory() - + # Assert mock_mkdir.assert_called_once_with(exist_ok=True) - @patch('claude_setup.config_manager.Path.mkdir') - @patch('builtins.open', new_callable=mock_open) - @patch('claude_setup.config_manager.ConfigManager.load_settings') + @patch("claude_setup.config_manager.Path.mkdir") + @patch("builtins.open", new_callable=mock_open) + @patch("claude_setup.config_manager.ConfigManager.load_settings") def test_save_settings_new_file(self, mock_load_settings, mock_file, mock_mkdir): """Test saving settings to a new file.""" # Arrange config_manager = ConfigManager() settings = {"CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": "us-west-2"} mock_load_settings.return_value = None - + # Act config_manager.save_settings(settings) - + # Assert mock_mkdir.assert_called_once_with(exist_ok=True) mock_load_settings.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'w') - - # Check that json.dump was called - the actual data is written by json.dump - # json.dump calls write multiple times, so we just verify write was called + mock_file.assert_called_once_with(config_manager.settings_path, "w") + + # Check that json.dump was called - the actual data is written + # by json.dump + # json.dump calls write multiple times, so we just verify + # write was called assert mock_file().write.called - @patch('claude_setup.config_manager.Path.mkdir') - @patch('builtins.open', new_callable=mock_open) - @patch('claude_setup.config_manager.ConfigManager.load_settings') - def test_save_settings_update_existing(self, mock_load_settings, mock_file, mock_mkdir): + @patch("claude_setup.config_manager.Path.mkdir") + @patch("builtins.open", new_callable=mock_open) + @patch("claude_setup.config_manager.ConfigManager.load_settings") + def test_save_settings_update_existing( + self, mock_load_settings, mock_file, mock_mkdir + ): """Test saving settings to update existing file.""" # Arrange config_manager = ConfigManager() - existing_settings = {"EXISTING_KEY": "existing_value", "CLAUDE_CODE_USE_BEDROCK": "0"} - new_settings = {"CLAUDE_CODE_USE_BEDROCK": "1", "AWS_REGION": "us-west-2"} + existing_settings = { + "EXISTING_KEY": "existing_value", + "CLAUDE_CODE_USE_BEDROCK": "0", + } + new_settings = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "AWS_REGION": "us-west-2", + } mock_load_settings.return_value = existing_settings - + # Act config_manager.save_settings(new_settings) - + # Assert mock_mkdir.assert_called_once_with(exist_ok=True) mock_load_settings.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'w') - + mock_file.assert_called_once_with(config_manager.settings_path, "w") + # Verify that write was called (json.dump will call write internally) assert mock_file().write.called - @patch('builtins.open', new_callable=mock_open, read_data='{"key": "value"}') - @patch('claude_setup.config_manager.Path.exists') + @patch("builtins.open", new_callable=mock_open, read_data='{"key": "value"}') + @patch("claude_setup.config_manager.Path.exists") def test_load_settings_success(self, mock_exists, mock_file): """Test successfully loading settings.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = True - + # Act result = config_manager.load_settings() - + # Assert assert result == {"key": "value"} mock_exists.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'r') + mock_file.assert_called_once_with(config_manager.settings_path, "r") - @patch('claude_setup.config_manager.Path.exists') + @patch("claude_setup.config_manager.Path.exists") def test_load_settings_file_not_exists(self, mock_exists): """Test loading settings when file doesn't exist.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = False - + # Act result = config_manager.load_settings() - + # Assert assert result is None mock_exists.assert_called_once() - @patch('builtins.open', new_callable=mock_open, read_data='invalid json') - @patch('claude_setup.config_manager.Path.exists') + @patch("builtins.open", new_callable=mock_open, read_data="invalid json") + @patch("claude_setup.config_manager.Path.exists") def test_load_settings_json_decode_error(self, mock_exists, mock_file): """Test loading settings with invalid JSON.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = True - + # Act result = config_manager.load_settings() - + # Assert assert result is None mock_exists.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'r') + mock_file.assert_called_once_with(config_manager.settings_path, "r") - @patch('builtins.open', side_effect=IOError("Permission denied")) - @patch('claude_setup.config_manager.Path.exists') + @patch("builtins.open", side_effect=IOError("Permission denied")) + @patch("claude_setup.config_manager.Path.exists") def test_load_settings_io_error(self, mock_exists, mock_file): """Test loading settings with IO error.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = True - + # Act result = config_manager.load_settings() - + # Assert assert result is None mock_exists.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'r') + mock_file.assert_called_once_with(config_manager.settings_path, "r") - @patch('claude_setup.config_manager.Path.unlink') - @patch('claude_setup.config_manager.Path.exists') + @patch("claude_setup.config_manager.Path.unlink") + @patch("claude_setup.config_manager.Path.exists") def test_reset_settings_file_exists(self, mock_exists, mock_unlink): """Test resetting settings when file exists.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = True - + # Act config_manager.reset_settings() - + # Assert mock_exists.assert_called_once() mock_unlink.assert_called_once() - @patch('claude_setup.config_manager.Path.unlink') - @patch('claude_setup.config_manager.Path.exists') + @patch("claude_setup.config_manager.Path.unlink") + @patch("claude_setup.config_manager.Path.exists") def test_reset_settings_file_not_exists(self, mock_exists, mock_unlink): """Test resetting settings when file doesn't exist.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = False - + # Act config_manager.reset_settings() - + # Assert mock_exists.assert_called_once() mock_unlink.assert_not_called() - @patch('claude_setup.config_manager.Path.mkdir') - @patch('builtins.open', new_callable=mock_open) - @patch('claude_setup.config_manager.ConfigManager.load_settings') + @patch("claude_setup.config_manager.Path.mkdir") + @patch("builtins.open", new_callable=mock_open) + @patch("claude_setup.config_manager.ConfigManager.load_settings") def test_save_settings_empty_dict(self, mock_load_settings, mock_file, mock_mkdir): """Test saving empty settings dictionary.""" # Arrange config_manager = ConfigManager() settings = {} mock_load_settings.return_value = None - + # Act config_manager.save_settings(settings) - + # Assert mock_mkdir.assert_called_once_with(exist_ok=True) mock_load_settings.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'w') - + mock_file.assert_called_once_with(config_manager.settings_path, "w") + # Check that write was called for empty dict assert mock_file().write.called - @patch('claude_setup.config_manager.Path.mkdir') - @patch('builtins.open', new_callable=mock_open) - @patch('claude_setup.config_manager.ConfigManager.load_settings') - def test_save_settings_special_characters(self, mock_load_settings, mock_file, mock_mkdir): + @patch("claude_setup.config_manager.Path.mkdir") + @patch("builtins.open", new_callable=mock_open) + @patch("claude_setup.config_manager.ConfigManager.load_settings") + def test_save_settings_special_characters( + self, mock_load_settings, mock_file, mock_mkdir + ): """Test saving settings with special characters.""" # Arrange config_manager = ConfigManager() settings = { "SPECIAL_CHARS": "!@#$%^&*()_+-=[]{}|;':\",./<>?", - "UNICODE": "héllo wørld 你好" + "UNICODE": "héllo wørld 你好", } mock_load_settings.return_value = None - + # Act config_manager.save_settings(settings) - + # Assert mock_mkdir.assert_called_once_with(exist_ok=True) mock_load_settings.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'w') + mock_file.assert_called_once_with(config_manager.settings_path, "w") assert mock_file().write.called - @patch('builtins.open', new_callable=mock_open, read_data='{}') - @patch('claude_setup.config_manager.Path.exists') + @patch("builtins.open", new_callable=mock_open, read_data="{}") + @patch("claude_setup.config_manager.Path.exists") def test_load_settings_empty_file(self, mock_exists, mock_file): """Test loading settings from empty JSON file.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = True - + # Act result = config_manager.load_settings() - + # Assert assert result == {} mock_exists.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'r') + mock_file.assert_called_once_with(config_manager.settings_path, "r") - @patch('claude_setup.config_manager.Path.mkdir', side_effect=PermissionError("Permission denied")) + @patch( + "claude_setup.config_manager.Path.mkdir", + side_effect=PermissionError("Permission denied"), + ) def test_save_settings_mkdir_permission_error(self, mock_mkdir): """Test saving settings when mkdir raises PermissionError.""" # Arrange config_manager = ConfigManager() settings = {"key": "value"} - + # Act & Assert with pytest.raises(PermissionError, match="Permission denied"): config_manager.save_settings(settings) - + mock_mkdir.assert_called_once_with(exist_ok=True) - @patch('claude_setup.config_manager.Path.mkdir') - @patch('builtins.open', side_effect=PermissionError("Permission denied")) - @patch('claude_setup.config_manager.ConfigManager.load_settings') - def test_save_settings_file_permission_error(self, mock_load_settings, mock_file, mock_mkdir): + @patch("claude_setup.config_manager.Path.mkdir") + @patch("builtins.open", side_effect=PermissionError("Permission denied")) + @patch("claude_setup.config_manager.ConfigManager.load_settings") + def test_save_settings_file_permission_error( + self, mock_load_settings, mock_file, mock_mkdir + ): """Test saving settings when file write raises PermissionError.""" # Arrange config_manager = ConfigManager() - settings = {"key": "value"} + settings = {"key": "value"} mock_load_settings.return_value = None - + # Act & Assert with pytest.raises(PermissionError, match="Permission denied"): config_manager.save_settings(settings) - + mock_mkdir.assert_called_once_with(exist_ok=True) mock_load_settings.assert_called_once() - mock_file.assert_called_once_with(config_manager.settings_path, 'w') + mock_file.assert_called_once_with(config_manager.settings_path, "w") - @patch('claude_setup.config_manager.Path.unlink', side_effect=PermissionError("Permission denied")) - @patch('claude_setup.config_manager.Path.exists') + @patch( + "claude_setup.config_manager.Path.unlink", + side_effect=PermissionError("Permission denied"), + ) + @patch("claude_setup.config_manager.Path.exists") def test_reset_settings_permission_error(self, mock_exists, mock_unlink): """Test resetting settings when unlink raises PermissionError.""" # Arrange config_manager = ConfigManager() mock_exists.return_value = True - + # Act & Assert with pytest.raises(PermissionError, match="Permission denied"): config_manager.reset_settings() - + mock_exists.assert_called_once() - mock_unlink.assert_called_once() \ No newline at end of file + mock_unlink.assert_called_once() diff --git a/tests/test_gitignore_manager.py b/tests/test_gitignore_manager.py index ffa04f9..c46d92d 100644 --- a/tests/test_gitignore_manager.py +++ b/tests/test_gitignore_manager.py @@ -1,7 +1,7 @@ """Tests for the gitignore_manager module.""" from pathlib import Path -from unittest.mock import patch, mock_open, MagicMock +from unittest.mock import patch, mock_open import pytest @@ -11,226 +11,267 @@ class TestEnsureGitignore: """Test cases for ensure_gitignore function.""" - @patch('builtins.open', new_callable=mock_open, read_data='') - @patch('claude_setup.gitignore_manager.Path.exists') + @patch("builtins.open", new_callable=mock_open, read_data="") + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_empty_file(self, mock_exists, mock_file): """Test adding pattern to empty .gitignore file.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() # Check that file was opened twice: once for read, once for write assert mock_file.call_count == 2 - mock_file.assert_any_call(Path(".gitignore"), 'r') - mock_file.assert_any_call(Path(".gitignore"), 'w') - + mock_file.assert_any_call(Path(".gitignore"), "r") + mock_file.assert_any_call(Path(".gitignore"), "w") + # Check the written content - write_call = [call for call in mock_file.return_value.write.call_args_list if call[0][0]][-1] + write_call = [ + call for call in mock_file.return_value.write.call_args_list if call[0][0] + ][-1] written_content = write_call[0][0] assert ".claude/settings.local.json\n" == written_content - @patch('builtins.open', new_callable=mock_open, read_data='node_modules/\n*.log\n') - @patch('claude_setup.gitignore_manager.Path.exists') + @patch( + "builtins.open", + new_callable=mock_open, + read_data="node_modules/\n*.log\n", + ) + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_existing_content(self, mock_exists, mock_file): """Test adding pattern to .gitignore with existing content.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() assert mock_file.call_count == 2 - mock_file.assert_any_call(Path(".gitignore"), 'r') - mock_file.assert_any_call(Path(".gitignore"), 'w') - + mock_file.assert_any_call(Path(".gitignore"), "r") + mock_file.assert_any_call(Path(".gitignore"), "w") + # Check the written content includes existing and new patterns - write_call = [call for call in mock_file.return_value.write.call_args_list if call[0][0]][-1] + write_call = [ + call for call in mock_file.return_value.write.call_args_list if call[0][0] + ][-1] written_content = write_call[0][0] assert "node_modules/\n*.log\n.claude/settings.local.json\n" == written_content - @patch('builtins.open', new_callable=mock_open, read_data='node_modules/\n.claude/settings.local.json\n*.log\n') - @patch('claude_setup.gitignore_manager.Path.exists') + @patch( + "builtins.open", + new_callable=mock_open, + read_data="node_modules/\n.claude/settings.local.json\n*.log\n", + ) + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_pattern_already_exists(self, mock_exists, mock_file): """Test that pattern is not added if it already exists.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() # Should only read the file, not write - mock_file.assert_called_once_with(Path(".gitignore"), 'r') + mock_file.assert_called_once_with(Path(".gitignore"), "r") - @patch('builtins.open', new_callable=mock_open) - @patch('claude_setup.gitignore_manager.Path.exists') + @patch("builtins.open", new_callable=mock_open) + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_file_not_exists(self, mock_exists, mock_file): """Test creating .gitignore when file doesn't exist.""" # Arrange mock_exists.return_value = False - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() # Should only write (create) the file - mock_file.assert_called_once_with(Path(".gitignore"), 'w') - + mock_file.assert_called_once_with(Path(".gitignore"), "w") + # Check the written content write_call = mock_file.return_value.write.call_args_list[0] written_content = write_call[0][0] assert ".claude/settings.local.json\n" == written_content - @patch('builtins.open', new_callable=mock_open, read_data=' \n\n \n') - @patch('claude_setup.gitignore_manager.Path.exists') + @patch("builtins.open", new_callable=mock_open, read_data=" \n\n \n") + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_whitespace_only_file(self, mock_exists, mock_file): """Test handling .gitignore with only whitespace.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() assert mock_file.call_count == 2 - mock_file.assert_any_call(Path(".gitignore"), 'r') - mock_file.assert_any_call(Path(".gitignore"), 'w') - + mock_file.assert_any_call(Path(".gitignore"), "r") + mock_file.assert_any_call(Path(".gitignore"), "w") + # Check the written content - write_call = [call for call in mock_file.return_value.write.call_args_list if call[0][0]][-1] + write_call = [ + call for call in mock_file.return_value.write.call_args_list if call[0][0] + ][-1] written_content = write_call[0][0] assert ".claude/settings.local.json\n" == written_content - @patch('builtins.open', new_callable=mock_open, read_data='node_modules/\n# Comment\n\n*.log\n') - @patch('claude_setup.gitignore_manager.Path.exists') - def test_ensure_gitignore_with_comments_and_empty_lines(self, mock_exists, mock_file): + @patch( + "builtins.open", + new_callable=mock_open, + read_data="node_modules/\n# Comment\n\n*.log\n", + ) + @patch("claude_setup.gitignore_manager.Path.exists") + def test_ensure_gitignore_with_comments_and_empty_lines( + self, mock_exists, mock_file + ): """Test handling .gitignore with comments and empty lines.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() assert mock_file.call_count == 2 - + # Check the written content preserves structure - write_call = [call for call in mock_file.return_value.write.call_args_list if call[0][0]][-1] + write_call = [ + call for call in mock_file.return_value.write.call_args_list if call[0][0] + ][-1] written_content = write_call[0][0] expected = "node_modules/\n# Comment\n\n*.log\n.claude/settings.local.json\n" assert expected == written_content - @patch('builtins.open', side_effect=PermissionError("Permission denied")) - @patch('claude_setup.gitignore_manager.Path.exists') + @patch("builtins.open", side_effect=PermissionError("Permission denied")) + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_read_permission_error(self, mock_exists, mock_file): """Test handling permission error when reading .gitignore.""" # Arrange mock_exists.return_value = True - + # Act & Assert with pytest.raises(PermissionError, match="Permission denied"): ensure_gitignore() - + mock_exists.assert_called_once() - mock_file.assert_called_once_with(Path(".gitignore"), 'r') + mock_file.assert_called_once_with(Path(".gitignore"), "r") - @patch('builtins.open') - @patch('claude_setup.gitignore_manager.Path.exists') + @patch("builtins.open") + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_write_permission_error(self, mock_exists, mock_file): """Test handling permission error when writing .gitignore.""" # Arrange mock_exists.return_value = True mock_file.side_effect = [ - mock_open(read_data='node_modules/\n').return_value, # Read succeeds - PermissionError("Permission denied") # Write fails + mock_open(read_data="node_modules/\n").return_value, # Read succeeds + PermissionError("Permission denied"), # Write fails ] - + # Act & Assert with pytest.raises(PermissionError, match="Permission denied"): ensure_gitignore() - + mock_exists.assert_called_once() assert mock_file.call_count == 2 - @patch('builtins.open', new_callable=mock_open, read_data='node_modules/\n.claude/settings.local.json\nsimilar-pattern\n') - @patch('claude_setup.gitignore_manager.Path.exists') + @patch( + "builtins.open", + new_callable=mock_open, + read_data=("node_modules/\n.claude/settings.local.json\n" "similar-pattern\n"), + ) + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_similar_pattern_exists(self, mock_exists, mock_file): """Test that exact pattern match is required.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() # Should only read the file since exact pattern exists - mock_file.assert_called_once_with(Path(".gitignore"), 'r') + mock_file.assert_called_once_with(Path(".gitignore"), "r") - @patch('builtins.open', new_callable=mock_open, read_data='node_modules/\n.claude/settings.local.json \n*.log\n') - @patch('claude_setup.gitignore_manager.Path.exists') - def test_ensure_gitignore_pattern_with_trailing_spaces(self, mock_exists, mock_file): + @patch( + "builtins.open", + new_callable=mock_open, + read_data="node_modules/\n.claude/settings.local.json \n*.log\n", + ) + @patch("claude_setup.gitignore_manager.Path.exists") + def test_ensure_gitignore_pattern_with_trailing_spaces( + self, mock_exists, mock_file + ): """Test that trailing spaces in existing pattern don't match.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() assert mock_file.call_count == 2 # Read and write - mock_file.assert_any_call(Path(".gitignore"), 'r') - mock_file.assert_any_call(Path(".gitignore"), 'w') - - # Check that the pattern was added despite similar line with trailing spaces - write_call = [call for call in mock_file.return_value.write.call_args_list if call[0][0]][-1] + mock_file.assert_any_call(Path(".gitignore"), "r") + mock_file.assert_any_call(Path(".gitignore"), "w") + + # Check that the pattern was added despite similar line + # with trailing spaces + write_call = [ + call for call in mock_file.return_value.write.call_args_list if call[0][0] + ][-1] written_content = write_call[0][0] assert ".claude/settings.local.json" in written_content # Should have both the original line with spaces and the new clean line - lines = written_content.strip().split('\n') + lines = written_content.strip().split("\n") assert ".claude/settings.local.json " in lines assert ".claude/settings.local.json" in lines - @patch('builtins.open', side_effect=IOError("Disk full")) - @patch('claude_setup.gitignore_manager.Path.exists') + @patch("builtins.open", side_effect=IOError("Disk full")) + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_io_error(self, mock_exists, mock_file): """Test handling IO error when accessing .gitignore.""" # Arrange mock_exists.return_value = True - + # Act & Assert with pytest.raises(IOError, match="Disk full"): ensure_gitignore() - + mock_exists.assert_called_once() - mock_file.assert_called_once_with(Path(".gitignore"), 'r') + mock_file.assert_called_once_with(Path(".gitignore"), "r") - @patch('builtins.open', new_callable=mock_open, read_data='line1\nline2\nline3') - @patch('claude_setup.gitignore_manager.Path.exists') + @patch( + "builtins.open", + new_callable=mock_open, + read_data="line1\nline2\nline3", + ) + @patch("claude_setup.gitignore_manager.Path.exists") def test_ensure_gitignore_no_trailing_newline(self, mock_exists, mock_file): """Test handling .gitignore without trailing newline.""" # Arrange mock_exists.return_value = True - + # Act ensure_gitignore() - + # Assert mock_exists.assert_called_once() assert mock_file.call_count == 2 - + # Check the written content adds pattern properly - write_call = [call for call in mock_file.return_value.write.call_args_list if call[0][0]][-1] + write_call = [ + call for call in mock_file.return_value.write.call_args_list if call[0][0] + ][-1] written_content = write_call[0][0] - assert "line1\nline2\nline3\n.claude/settings.local.json\n" == written_content \ No newline at end of file + assert "line1\nline2\nline3\n.claude/settings.local.json\n" == written_content diff --git a/tests/test_integration.py b/tests/test_integration.py index c5c5bcd..668dc81 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,6 +2,7 @@ import json import os +import sys import tempfile from pathlib import Path from unittest.mock import patch, MagicMock @@ -9,10 +10,14 @@ import pytest from click.testing import CliRunner +# Import modules to ensure they're in sys.modules +import claude_setup.cli +import claude_setup.auth_checker +import claude_setup.aws_client + from claude_setup.cli import cli from claude_setup.config_manager import ConfigManager from claude_setup.gitignore_manager import ensure_gitignore -from tests.test_utils import create_temp_settings_file, create_temp_gitignore class TestEndToEndWorkflow: @@ -20,162 +25,190 @@ class TestEndToEndWorkflow: def setup_method(self): """Set up test fixtures.""" - self.runner = CliRunner() + self.runner = CliRunner(env={"NO_COLOR": "1"}) - @patch('claude_setup.cli.ensure_gitignore') - @patch('claude_setup.cli.check_aws_auth') - @patch('claude_setup.aws_client.subprocess.run') - def test_complete_setup_workflow(self, mock_subprocess, mock_auth, mock_gitignore, mock_aws_response): + @patch.object(sys.modules["claude_setup.cli"], "ensure_gitignore") + @patch.object(sys.modules["claude_setup.auth_checker"], "check_aws_auth") + @patch("claude_setup.aws_client.subprocess.run") + def test_complete_setup_workflow( + self, mock_subprocess, mock_auth, mock_gitignore, mock_aws_response + ): # noqa: W0613 """Test complete setup workflow from start to finish.""" # Arrange mock_auth.return_value = True mock_subprocess.return_value = MagicMock( - returncode=0, - stdout=json.dumps(mock_aws_response) + returncode=0, stdout=json.dumps(mock_aws_response) ) - + with self.runner.isolated_filesystem(): # Act - Run setup - result = self.runner.invoke(cli, ['setup', '--non-interactive']) - + result = self.runner.invoke(cli, ["setup", "--non-interactive"]) + # Assert setup succeeded assert result.exit_code == 0 assert "Configuration saved successfully!" in result.output - + # Verify configuration file was created config_manager = ConfigManager() settings = config_manager.load_settings() assert settings is not None - assert settings['CLAUDE_CODE_USE_BEDROCK'] == '1' - assert settings['AWS_REGION'] == 'us-west-2' - assert 'ANTHROPIC_MODEL' in settings - + assert settings["CLAUDE_CODE_USE_BEDROCK"] == "1" + assert settings["AWS_REGION"] == "us-west-2" + assert "ANTHROPIC_MODEL" in settings + # Test status command - status_result = self.runner.invoke(cli, ['status']) + status_result = self.runner.invoke(cli, ["status"]) assert status_result.exit_code == 0 assert "Claude Bedrock Configuration" in status_result.output assert "CLAUDE_CODE_USE_BEDROCK: 1" in status_result.output - + # Test reset command - reset_result = self.runner.invoke(cli, ['reset'], input='y\n') + reset_result = self.runner.invoke(cli, ["reset"], input="y\n") assert reset_result.exit_code == 0 assert "Configuration reset successfully" in reset_result.output - + # Verify configuration was reset settings_after_reset = config_manager.load_settings() assert settings_after_reset is None - + # Test status after reset - status_after_reset = self.runner.invoke(cli, ['status']) + status_after_reset = self.runner.invoke(cli, ["status"]) assert status_after_reset.exit_code == 0 assert "No configuration found" in status_after_reset.output - @patch('claude_setup.cli.check_aws_auth') - @patch('claude_setup.aws_client.subprocess.run') - def test_setup_with_different_regions(self, mock_subprocess, mock_auth, mock_aws_response): + @patch.object(sys.modules["claude_setup.auth_checker"], "check_aws_auth") + @patch("claude_setup.aws_client.subprocess.run") + def test_setup_with_different_regions( + self, mock_subprocess, mock_auth, mock_aws_response + ): """Test setup workflow with different AWS regions.""" # Arrange mock_auth.return_value = True mock_subprocess.return_value = MagicMock( - returncode=0, - stdout=json.dumps(mock_aws_response) + returncode=0, stdout=json.dumps(mock_aws_response) ) - - regions = ['us-east-1', 'eu-west-1', 'ap-southeast-1'] - + + regions = ["us-east-1", "eu-west-1", "ap-southeast-1"] + for region in regions: with self.runner.isolated_filesystem(): # Act - result = self.runner.invoke(cli, ['setup', '--region', region, '--non-interactive']) - + result = self.runner.invoke( + cli, ["setup", "--region", region, "--non-interactive"] + ) + # Assert assert result.exit_code == 0 - assert f"Fetching available Claude models from {region}" in result.output - + assert ( + f"Fetching available Claude models from {region}" in result.output + ) + # Verify region in configuration config_manager = ConfigManager() settings = config_manager.load_settings() - assert settings['AWS_REGION'] == region - + assert settings["AWS_REGION"] == region + # Verify AWS CLI was called with correct region mock_subprocess.assert_called_with( - ['aws', 'bedrock', 'list-inference-profiles', '--region', region], + [ + "aws", + "bedrock", + "list-inference-profiles", + "--region", + region, + ], capture_output=True, text=True, - check=True + check=True, ) + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_gitignore_integration(self): """Test gitignore functionality integration.""" + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - # Test with no existing .gitignore - ensure_gitignore() - gitignore_path = temp_path / ".gitignore" - assert gitignore_path.exists() - with open(gitignore_path) as f: - content = f.read() - assert ".claude/settings.local.json" in content - - # Test with existing .gitignore - existing_content = "node_modules/\n*.log\n" - with open(gitignore_path, 'w') as f: - f.write(existing_content) - - ensure_gitignore() - with open(gitignore_path) as f: - updated_content = f.read() - assert existing_content.strip() in updated_content - assert ".claude/settings.local.json" in updated_content - - # Test idempotent behavior - ensure_gitignore() - with open(gitignore_path) as f: - final_content = f.read() - # Should only have one instance of the pattern - assert final_content.count(".claude/settings.local.json") == 1 + try: + os.chdir(temp_path) + + # Test with no existing .gitignore + ensure_gitignore() + gitignore_path = temp_path / ".gitignore" + assert gitignore_path.exists() + with open(gitignore_path) as f: + content = f.read() + assert ".claude/settings.local.json" in content + + # Test with existing .gitignore + existing_content = "node_modules/\n*.log\n" + with open(gitignore_path, "w") as f: + f.write(existing_content) + + ensure_gitignore() + with open(gitignore_path) as f: + updated_content = f.read() + assert existing_content.strip() in updated_content + assert ".claude/settings.local.json" in updated_content + + # Test idempotent behavior + ensure_gitignore() + with open(gitignore_path) as f: + final_content = f.read() + # Should only have one instance of the pattern + assert final_content.count(".claude/settings.local.json") == 1 + finally: + os.chdir(original_dir) + + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_config_manager_integration(self): """Test config manager functionality integration.""" + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - config_manager = ConfigManager() - - # Test saving new settings - initial_settings = { - "CLAUDE_CODE_USE_BEDROCK": "1", - "AWS_REGION": "us-west-2" - } - config_manager.save_settings(initial_settings) - - # Verify directory and file creation - assert config_manager.claude_dir.exists() - assert config_manager.settings_path.exists() - - # Test loading settings - loaded_settings = config_manager.load_settings() - assert loaded_settings == initial_settings - - # Test updating settings - additional_settings = { - "ANTHROPIC_MODEL": "claude-3-sonnet", - "MAX_THINKING_TOKENS": "2048" - } - config_manager.save_settings(additional_settings) - - # Verify merge behavior - updated_settings = config_manager.load_settings() - expected_settings = {**initial_settings, **additional_settings} - assert updated_settings == expected_settings - - # Test reset - config_manager.reset_settings() - assert not config_manager.settings_path.exists() - assert config_manager.load_settings() is None + try: + os.chdir(temp_path) + + config_manager = ConfigManager() + + # Test saving new settings + initial_settings = { + "CLAUDE_CODE_USE_BEDROCK": "1", + "AWS_REGION": "us-west-2", + } + config_manager.save_settings(initial_settings) + + # Verify directory and file creation + assert config_manager.claude_dir.exists() + assert config_manager.settings_path.exists() + + # Test loading settings + loaded_settings = config_manager.load_settings() + assert loaded_settings == initial_settings + + # Test updating settings + additional_settings = { + "ANTHROPIC_MODEL": "claude-3-sonnet", + "MAX_THINKING_TOKENS": "2048", + } + config_manager.save_settings(additional_settings) + + # Verify merge behavior + updated_settings = config_manager.load_settings() + expected_settings = {**initial_settings, **additional_settings} + assert updated_settings == expected_settings + + # Test reset + config_manager.reset_settings() + assert not config_manager.settings_path.exists() + assert config_manager.load_settings() is None + + finally: + os.chdir(original_dir) class TestErrorHandlingIntegration: @@ -183,184 +216,253 @@ class TestErrorHandlingIntegration: def setup_method(self): """Set up test fixtures.""" - self.runner = CliRunner() + self.runner = CliRunner(env={"NO_COLOR": "1"}) - @patch('claude_setup.cli.check_aws_auth') + @patch.object(sys.modules["claude_setup.auth_checker"], "check_aws_auth") + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_auth_failure_workflow(self, mock_auth): """Test workflow when AWS authentication fails.""" # Arrange mock_auth.return_value = False - + + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - # Act - result = self.runner.invoke(cli, ['setup']) - - # Assert - assert result.exit_code == 1 - assert "Not authenticated with AWS" in result.output - assert "aws configure" in result.output - - # Verify no configuration was created - config_manager = ConfigManager() - assert config_manager.load_settings() is None + try: + os.chdir(temp_path) + + # Act + result = self.runner.invoke(cli, ["setup"]) - @patch('claude_setup.cli.check_aws_auth') - @patch('claude_setup.aws_client.subprocess.run') - def test_bedrock_api_error_workflow(self, mock_subprocess, mock_auth): + # Assert + assert result.exit_code == 1 + assert "Not authenticated with AWS" in result.output + assert "aws configure" in result.output + + # Verify no configuration was created + config_manager = ConfigManager() + assert config_manager.load_settings() is None + + finally: + os.chdir(original_dir) + + @patch("claude_setup.aws_client.subprocess.run") + @patch.object(sys.modules["claude_setup.cli"], "check_aws_auth") + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) + def test_bedrock_api_error_workflow(self, mock_auth, mock_subprocess): """Test workflow when Bedrock API returns error.""" # Arrange mock_auth.return_value = True - mock_subprocess.side_effect = Exception("AccessDeniedException: Not authorized") - + # subprocess.run is called when listing models - should raise CalledProcessError + from subprocess import CalledProcessError + + mock_subprocess.side_effect = CalledProcessError( + 1, "aws bedrock", stderr="AccessDeniedException: Not authorized" + ) + + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - # Act & Assert - with pytest.raises(Exception, match="AccessDeniedException"): - self.runner.invoke(cli, ['setup', '--non-interactive'], catch_exceptions=False) - - # Verify no configuration was created - config_manager = ConfigManager() - assert config_manager.load_settings() is None + try: + os.chdir(temp_path) + + # Act + result = self.runner.invoke(cli, ["setup", "--non-interactive"]) + # Assert - The exception from BedrockClient causes the CLI to exit + assert result.exit_code != 0 + # The exception message should appear in the result + assert ( + "Access denied" in str(result.exception) + or "Access denied" in result.output + ) + + # Verify no configuration was created + config_manager = ConfigManager() + assert config_manager.load_settings() is None + + finally: + os.chdir(original_dir) + + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_permission_error_workflow(self): """Test workflow when permission errors occur.""" + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - # Create a read-only directory to simulate permission error - claude_dir = temp_path / ".claude" - claude_dir.mkdir() - os.chmod(claude_dir, 0o444) # Read-only - try: - config_manager = ConfigManager() - settings = {"test": "value"} - - # This should raise a permission error - with pytest.raises((PermissionError, OSError)): - config_manager.save_settings(settings) - + os.chdir(temp_path) + + # Create a read-only directory to simulate permission error + claude_dir = temp_path / ".claude" + claude_dir.mkdir() + os.chmod(claude_dir, 0o500) # Read-only for owner + + try: + config_manager = ConfigManager() + settings = {"test": "value"} + + # This should raise a permission error + with pytest.raises((PermissionError, OSError)): + config_manager.save_settings(settings) + + finally: + # Restore permissions for cleanup + os.chmod(claude_dir, 0o700) + finally: - # Restore permissions for cleanup - os.chmod(claude_dir, 0o755) + os.chdir(original_dir) + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_corrupted_config_recovery(self): """Test recovery from corrupted configuration file.""" + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - config_manager = ConfigManager() - config_manager.ensure_claude_directory() - - # Create corrupted JSON file - with open(config_manager.settings_path, 'w') as f: - f.write("invalid json content {") - - # Should handle corrupted file gracefully - result = config_manager.load_settings() - assert result is None - - # Should be able to save new settings over corrupted file - new_settings = {"CLAUDE_CODE_USE_BEDROCK": "1"} - config_manager.save_settings(new_settings) - - # Verify recovery - loaded_settings = config_manager.load_settings() - assert loaded_settings == new_settings + try: + os.chdir(temp_path) + + config_manager = ConfigManager() + config_manager.ensure_claude_directory() + + # Create corrupted JSON file + with open(config_manager.settings_path, "w") as f: + f.write("invalid json content {") + + # Should handle corrupted file gracefully + result = config_manager.load_settings() + assert result is None + + # Should be able to save new settings over corrupted file + new_settings = {"CLAUDE_CODE_USE_BEDROCK": "1"} + config_manager.save_settings(new_settings) + + # Verify recovery + loaded_settings = config_manager.load_settings() + assert loaded_settings == new_settings + + finally: + os.chdir(original_dir) class TestConcurrencyAndFileSystemEdgeCases: """Test edge cases related to file system operations.""" + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_concurrent_gitignore_updates(self): """Test handling concurrent .gitignore updates.""" + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - # Create initial .gitignore - gitignore_path = temp_path / ".gitignore" - with open(gitignore_path, 'w') as f: - f.write("initial_content\n") - - # Simulate concurrent modification by changing file between read and write - original_ensure = ensure_gitignore - - def mock_ensure_with_race_condition(): - # Read the file - with open(gitignore_path, 'r') as f: - content = f.read() - lines = content.strip().split('\n') if content.strip() else [] - - # Simulate another process modifying the file - with open(gitignore_path, 'w') as f: - f.write("initial_content\nconcurrent_addition\n") - - # Continue with original logic - claude_settings_pattern = ".claude/settings.local.json" - if claude_settings_pattern not in lines: - lines.append(claude_settings_pattern) - with open(gitignore_path, 'w') as f: - f.write('\n'.join(lines) + '\n') - - # Run the modified function - mock_ensure_with_race_condition() - - # Verify the result handles the race condition appropriately - with open(gitignore_path) as f: - final_content = f.read() - - # The exact result may vary, but it should contain our pattern - assert ".claude/settings.local.json" in final_content + try: + os.chdir(temp_path) + + # Create initial .gitignore + gitignore_path = temp_path / ".gitignore" + with open(gitignore_path, "w") as f: + f.write("initial_content\n") + + # Simulate concurrent modification by changing file + # between read and write + # original_ensure = ensure_gitignore # noqa: F841 + def mock_ensure_with_race_condition(): + # Read the file + with open(gitignore_path, "r") as f: + content = f.read() + lines = content.strip().split("\n") if content.strip() else [] + + # Simulate another process modifying the file + with open(gitignore_path, "w") as f: + f.write("initial_content\nconcurrent_addition\n") + + # Continue with original logic + claude_settings_pattern = ".claude/settings.local.json" + if claude_settings_pattern not in lines: + lines.append(claude_settings_pattern) + with open(gitignore_path, "w") as f: + f.write("\n".join(lines) + "\n") + + # Run the modified function + mock_ensure_with_race_condition() + + # Verify the result handles the race condition appropriately + with open(gitignore_path) as f: + final_content = f.read() + + # The exact result may vary, but it should contain our pattern + assert ".claude/settings.local.json" in final_content + + finally: + os.chdir(original_dir) + + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_symlink_handling(self): """Test handling of symlinks in configuration paths.""" + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) - os.chdir(temp_path) - - # Create actual claude directory - real_claude_dir = temp_path / "real_claude" - real_claude_dir.mkdir() - - # Create symlink - symlink_path = temp_path / ".claude" - symlink_path.symlink_to(real_claude_dir) - - # Test that config manager works with symlinked directory - config_manager = ConfigManager() - settings = {"test": "value"} - config_manager.save_settings(settings) - - # Verify file was created in real directory - real_settings_path = real_claude_dir / "settings.local.json" - assert real_settings_path.exists() - - # Verify loading works through symlink - loaded_settings = config_manager.load_settings() - assert loaded_settings == settings + try: + os.chdir(temp_path) + + # Create actual claude directory + real_claude_dir = temp_path / "real_claude" + real_claude_dir.mkdir() + + # Create symlink + symlink_path = temp_path / ".claude" + symlink_path.symlink_to(real_claude_dir) + + # Test that config manager works with symlinked directory + config_manager = ConfigManager() + settings = {"test": "value"} + config_manager.save_settings(settings) + + # Verify file was created in real directory + real_settings_path = real_claude_dir / "settings.local.json" + assert real_settings_path.exists() + + # Verify loading works through symlink + loaded_settings = config_manager.load_settings() + assert loaded_settings == settings + finally: + os.chdir(original_dir) + + @pytest.mark.skipif( + sys.platform == "win32", reason="Temporary directory cleanup issues on Windows" + ) def test_special_characters_in_paths(self): """Test handling paths with special characters.""" # This test would need to be adapted based on the operating system # For now, we'll test basic functionality + original_dir = os.getcwd() with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Create directory with spaces special_dir = temp_path / "dir with spaces" special_dir.mkdir() - os.chdir(special_dir) - - config_manager = ConfigManager() - settings = {"test": "value with spaces and unicode: 你好"} - config_manager.save_settings(settings) - - loaded_settings = config_manager.load_settings() - assert loaded_settings == settings \ No newline at end of file + try: + os.chdir(special_dir) + + config_manager = ConfigManager() + settings = {"test": "value with spaces and unicode: 你好"} + config_manager.save_settings(settings) + + loaded_settings = config_manager.load_settings() + assert loaded_settings == settings + + finally: + os.chdir(original_dir) diff --git a/tests/test_utils.py b/tests/test_utils.py index e6f4622..d475254 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,55 +1,53 @@ """Test utilities and helper functions for claude-bedrock-setup tests.""" import json -import tempfile -from pathlib import Path from unittest.mock import MagicMock def create_temp_settings_file(settings_dict, temp_dir): """Create a temporary settings file for testing. - + Args: settings_dict: Dictionary of settings to write temp_dir: Temporary directory path - + Returns: Path to the created settings file """ claude_dir = temp_dir / ".claude" claude_dir.mkdir(exist_ok=True) - + settings_file = claude_dir / "settings.local.json" - with open(settings_file, 'w') as f: + with open(settings_file, "w") as f: json.dump(settings_dict, f, indent=2) - + return settings_file def create_temp_gitignore(content, temp_dir): """Create a temporary .gitignore file for testing. - + Args: content: Content to write to .gitignore temp_dir: Temporary directory path - + Returns: Path to the created .gitignore file """ gitignore_file = temp_dir / ".gitignore" - with open(gitignore_file, 'w') as f: + with open(gitignore_file, "w") as f: f.write(content) - + return gitignore_file def mock_subprocess_success(stdout_data="", stderr_data=""): """Create a mock subprocess.run for successful execution. - + Args: stdout_data: Data to return as stdout stderr_data: Data to return as stderr - + Returns: Mock object configured for successful subprocess execution """ @@ -62,12 +60,12 @@ def mock_subprocess_success(stdout_data="", stderr_data=""): def mock_subprocess_failure(returncode=1, stdout_data="", stderr_data=""): """Create a mock subprocess.run for failed execution. - + Args: returncode: Return code for the failed process stdout_data: Data to return as stdout stderr_data: Data to return as stderr - + Returns: Mock object configured for failed subprocess execution """ @@ -80,27 +78,27 @@ def mock_subprocess_failure(returncode=1, stdout_data="", stderr_data=""): class MockPath: """Mock Path object for testing file system operations.""" - + def __init__(self, path_str, exists=True): self.path_str = path_str self._exists = exists self.mkdir_called = False self.unlink_called = False - + def __str__(self): return self.path_str - + def __truediv__(self, other): return MockPath(f"{self.path_str}/{other}", self._exists) - + def exists(self): return self._exists - + def mkdir(self, exist_ok=False): self.mkdir_called = True if not exist_ok and self._exists: raise FileExistsError("Directory already exists") - + def unlink(self): if not self._exists: raise FileNotFoundError("File does not exist") @@ -109,7 +107,7 @@ def unlink(self): def assert_subprocess_called_with(mock_run, expected_cmd, expected_kwargs=None): """Assert that subprocess.run was called with expected arguments. - + Args: mock_run: Mock subprocess.run object expected_cmd: Expected command list @@ -117,9 +115,9 @@ def assert_subprocess_called_with(mock_run, expected_cmd, expected_kwargs=None): """ mock_run.assert_called_once() call_args, call_kwargs = mock_run.call_args - + assert call_args[0] == expected_cmd - + if expected_kwargs: for key, value in expected_kwargs.items(): assert call_kwargs.get(key) == value @@ -127,55 +125,58 @@ def assert_subprocess_called_with(mock_run, expected_cmd, expected_kwargs=None): def create_mock_aws_response(models_data): """Create a mock AWS list-inference-profiles response. - + Args: - models_data: List of model dictionaries with keys: id, name, arn, status - + models_data: List of model dictionaries with keys: + id, name, arn, status + Returns: Dictionary in AWS response format """ summaries = [] for model in models_data: summary = { - 'inferenceProfileId': model['id'], - 'inferenceProfileName': model.get('name', model['id']), - 'inferenceProfileArn': model.get('arn', ''), - 'status': model.get('status', 'ACTIVE') + "inferenceProfileId": model["id"], + "inferenceProfileName": model.get("name", model["id"]), + "inferenceProfileArn": model.get("arn", ""), + "status": model.get("status", "ACTIVE"), } summaries.append(summary) - - return {'inferenceProfileSummaries': summaries} + + return {"inferenceProfileSummaries": summaries} class CLITestHelper: """Helper class for CLI testing.""" - + @staticmethod - def run_cli_command(runner, command, args=None, input_data=None, catch_exceptions=True): + def run_cli_command( + runner, command, args=None, input_data=None, catch_exceptions=True + ): """Run a CLI command with standard error handling. - + Args: runner: Click test runner command: CLI command to run args: Command arguments list input_data: Input to provide to command catch_exceptions: Whether to catch exceptions - + Returns: Click Result object """ cmd_args = args or [] return runner.invoke( - command, - cmd_args, - input=input_data, - catch_exceptions=catch_exceptions + command, + cmd_args, + input=input_data, + catch_exceptions=catch_exceptions, ) - + @staticmethod def assert_success(result, expected_output=None): """Assert that CLI command succeeded. - + Args: result: Click Result object expected_output: Expected output string (optional) @@ -183,16 +184,18 @@ def assert_success(result, expected_output=None): assert result.exit_code == 0, f"Command failed with output: {result.output}" if expected_output: assert expected_output in result.output - + @staticmethod def assert_failure(result, expected_exit_code=1, expected_output=None): """Assert that CLI command failed. - + Args: result: Click Result object expected_exit_code: Expected exit code expected_output: Expected output string (optional) """ - assert result.exit_code == expected_exit_code, f"Expected exit code {expected_exit_code}, got {result.exit_code}" + assert ( + result.exit_code == expected_exit_code + ), f"Expected exit code {expected_exit_code}, got {result.exit_code}" if expected_output: - assert expected_output in result.output \ No newline at end of file + assert expected_output in result.output