ara-cli 0.1.9.86__py3-none-any.whl → 0.1.9.87__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ara-cli might be problematic. Click here for more details.

ara_cli/ara_config.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from typing import List, Dict, Optional, Any
2
- from pydantic import BaseModel, ValidationError, Field, field_validator, model_validator
2
+ from pydantic import BaseModel, ValidationError, Field, model_validator
3
3
  import json
4
4
  import os
5
5
  from os.path import exists, dirname
@@ -14,23 +14,11 @@ class LLMConfigItem(BaseModel):
14
14
  model: str
15
15
  temperature: float = Field(ge=0.0, le=1.0)
16
16
  max_tokens: Optional[int] = None
17
-
18
- @field_validator('temperature')
19
- @classmethod
20
- def validate_temperature(cls, v: float, info) -> float:
21
- if not 0.0 <= v <= 1.0:
22
- print(f"Warning: Temperature is outside the 0.0 to 1.0 range")
23
- # Return a valid default
24
- return 0.8
25
- return v
26
-
27
- class ExtCodeDirItem(BaseModel):
28
- source_dir: str
29
17
 
30
18
  class ARAconfig(BaseModel):
31
- ext_code_dirs: List[ExtCodeDirItem] = Field(default_factory=lambda: [
32
- ExtCodeDirItem(source_dir="./src"),
33
- ExtCodeDirItem(source_dir="./tests")
19
+ ext_code_dirs: List[Dict[str, str]] = Field(default_factory=lambda: [
20
+ {"source_dir": "./src"},
21
+ {"source_dir": "./tests"}
34
22
  ])
35
23
  glossary_dir: str = "./glossary"
36
24
  doc_dir: str = "./docs"
@@ -98,27 +86,21 @@ class ARAconfig(BaseModel):
98
86
  )
99
87
  })
100
88
  default_llm: Optional[str] = "gpt-4o"
101
-
102
- model_config = {
103
- "extra": "forbid" # This will help identify unrecognized keys
104
- }
105
89
 
106
90
  @model_validator(mode='after')
107
91
  def check_critical_fields(self) -> 'ARAconfig':
108
- """Check for empty critical fields and use defaults if needed"""
92
+ """Check for empty critical fields and use defaults if needed."""
109
93
  critical_fields = {
110
- 'ext_code_dirs': [ExtCodeDirItem(source_dir="./src"), ExtCodeDirItem(source_dir="./tests")],
94
+ 'ext_code_dirs': [{"source_dir": "./src"}, {"source_dir": "./tests"}],
111
95
  'local_ara_templates_dir': "./ara/.araconfig/templates/",
112
96
  'local_prompt_templates_dir': "./ara/.araconfig",
113
97
  'glossary_dir': "./glossary"
114
98
  }
115
-
99
+
116
100
  for field, default_value in critical_fields.items():
117
101
  current_value = getattr(self, field)
118
- if (not current_value or
119
- (isinstance(current_value, list) and len(current_value) == 0) or
120
- (isinstance(current_value, str) and current_value.strip() == "")):
121
- print(f"Warning: Value for '{field}' is missing or empty.")
102
+ if not current_value:
103
+ print(f"Warning: Value for '{field}' is missing or empty. Using default.")
122
104
  setattr(self, field, default_value)
123
105
 
124
106
  return self
@@ -126,109 +108,80 @@ class ARAconfig(BaseModel):
126
108
  # Function to ensure the necessary directories exist
127
109
  @lru_cache(maxsize=None)
128
110
  def ensure_directory_exists(directory: str):
111
+ """Creates a directory if it doesn't exist."""
129
112
  if not exists(directory):
130
113
  os.makedirs(directory)
131
114
  print(f"New directory created at {directory}")
132
115
  return directory
133
116
 
134
- def handle_unrecognized_keys(data: dict, known_fields: set) -> dict:
135
- """Remove unrecognized keys and warn the user"""
117
+ def handle_unrecognized_keys(data: dict) -> dict:
118
+ """Removes unrecognized keys from the data and warns the user."""
119
+ known_fields = set(ARAconfig.model_fields.keys())
136
120
  cleaned_data = {}
137
121
  for key, value in data.items():
138
122
  if key not in known_fields:
139
- print(f"Warning: {key} is not recognized as a valid configuration option.")
123
+ print(f"Warning: Unrecognized configuration key '{key}' will be ignored.")
140
124
  else:
141
125
  cleaned_data[key] = value
142
126
  return cleaned_data
143
127
 
144
- def fix_llm_temperatures(data: dict) -> dict:
145
- """Fix invalid temperatures in LLM configurations"""
146
- if 'llm_config' in data:
147
- for model_key, model_config in data['llm_config'].items():
148
- if isinstance(model_config, dict) and 'temperature' in model_config:
149
- temp = model_config['temperature']
150
- if not 0.0 <= temp <= 1.0:
151
- print(f"Warning: Temperature for model '{model_key}' is outside the 0.0 to 1.0 range")
152
- model_config['temperature'] = 0.8
153
- return data
154
-
155
- def validate_and_fix_config_data(filepath: str) -> dict:
156
- """Load, validate, and fix configuration data"""
157
- try:
158
- with open(filepath, "r", encoding="utf-8") as file:
159
- data = json.load(file)
160
-
161
- # Get known fields from the ARAconfig model
162
- known_fields = set(ARAconfig.model_fields.keys())
163
-
164
- # Handle unrecognized keys
165
- data = handle_unrecognized_keys(data, known_fields)
166
-
167
- # Fix LLM temperatures before validation
168
- data = fix_llm_temperatures(data)
169
-
170
- return data
171
- except json.JSONDecodeError as e:
172
- print(f"Error: Invalid JSON in configuration file: {e}")
173
- print("Creating new configuration with defaults...")
174
- return {}
175
- except Exception as e:
176
- print(f"Error reading configuration file: {e}")
177
- return {}
178
-
179
128
  # Function to read the JSON file and return an ARAconfig model
180
129
  @lru_cache(maxsize=1)
181
130
  def read_data(filepath: str) -> ARAconfig:
182
- # Ensure the directory for the config file exists
183
- config_dir = dirname(filepath)
184
- ensure_directory_exists(config_dir)
131
+ """
132
+ Reads, validates, and repairs the configuration file.
133
+ If the file doesn't exist, it creates a default one.
134
+ If the file is invalid, it corrects only the broken parts.
135
+ """
136
+ ensure_directory_exists(dirname(filepath))
185
137
 
186
138
  if not exists(filepath):
187
- # If the file does not exist, create it with default values
139
+ print(f"Configuration file not found. Creating a default one at '{filepath}'.")
188
140
  default_config = ARAconfig()
189
141
  save_data(filepath, default_config)
190
- print(
191
- f"ara-cli configuration file '{filepath}' created with default configuration."
192
- f" Please modify it as needed and re-run your command"
193
- )
194
- sys.exit(0) # Exit the application
142
+ print("Please review the default configuration and re-run your command.")
143
+ sys.exit(0)
144
+
145
+ try:
146
+ with open(filepath, "r", encoding="utf-8") as file:
147
+ data = json.load(file)
148
+ except json.JSONDecodeError as e:
149
+ print(f"Error: Invalid JSON in configuration file: {e}")
150
+ print("Creating a new configuration with defaults...")
151
+ default_config = ARAconfig()
152
+ save_data(filepath, default_config)
153
+ return default_config
154
+
155
+ data = handle_unrecognized_keys(data)
195
156
 
196
- # Validate and load the existing configuration
197
- data = validate_and_fix_config_data(filepath)
198
-
199
157
  try:
200
- # Try to create the config with the loaded data
201
158
  config = ARAconfig(**data)
202
-
203
- # Save the potentially fixed configuration back
204
159
  save_data(filepath, config)
205
-
206
160
  return config
207
161
  except ValidationError as e:
208
- print(f"ValidationError: {e}")
209
- print("Correcting configuration with default values...")
162
+ print("--- Configuration Error Detected ---")
163
+ print("Some settings in your configuration file are invalid. Attempting to fix them.")
210
164
 
211
- # Create a default config
212
- default_config = ARAconfig()
165
+ corrected_data = data.copy()
166
+ defaults = ARAconfig().model_dump()
213
167
 
214
- # Try to preserve valid fields from the original data
215
- for field_name, field_value in data.items():
216
- if field_name in ARAconfig.model_fields:
217
- try:
218
- # Attempt to set the field value
219
- setattr(default_config, field_name, field_value)
220
- except:
221
- # If it fails, keep the default
222
- pass
168
+ error_fields = {err['loc'][0] for err in e.errors() if err['loc']}
223
169
 
224
- # Save the corrected configuration
225
- save_data(filepath, default_config)
226
- print("Fixed configuration saved to file.")
170
+ for field_name in error_fields:
171
+ print(f"-> Field '{field_name}' is invalid and will be reverted to its default value.")
172
+ corrected_data[field_name] = defaults.get(field_name)
173
+
174
+ print("--- End of Error Report ---")
175
+
176
+ final_config = ARAconfig(**corrected_data)
177
+ save_data(filepath, final_config)
178
+ print(f"Configuration has been corrected and saved to '{filepath}'.")
227
179
 
228
- return default_config
180
+ return final_config
229
181
 
230
182
  # Function to save the modified configuration back to the JSON file
231
183
  def save_data(filepath: str, config: ARAconfig):
184
+ """Saves the Pydantic config model to a JSON file."""
232
185
  with open(filepath, "w", encoding="utf-8") as file:
233
186
  json.dump(config.model_dump(), file, indent=4)
234
187
 
@@ -237,18 +190,13 @@ class ConfigManager:
237
190
  _config_instance = None
238
191
 
239
192
  @classmethod
240
- def get_config(cls, filepath=DEFAULT_CONFIG_LOCATION):
193
+ def get_config(cls, filepath=DEFAULT_CONFIG_LOCATION) -> ARAconfig:
241
194
  if cls._config_instance is None:
242
- config_dir = dirname(filepath)
243
-
244
- if not exists(config_dir):
245
- makedirs(config_dir)
246
-
247
195
  cls._config_instance = read_data(filepath)
248
196
  return cls._config_instance
249
197
 
250
198
  @classmethod
251
199
  def reset(cls):
252
- """Reset the configuration instance (useful for testing)"""
200
+ """Reset the configuration instance (useful for testing)."""
253
201
  cls._config_instance = None
254
202
  read_data.cache_clear()
ara_cli/prompt_handler.py CHANGED
@@ -15,8 +15,6 @@ import glob
15
15
  import logging
16
16
 
17
17
 
18
-
19
-
20
18
  class LLMSingleton:
21
19
  _instance = None
22
20
  _model = None
@@ -27,8 +25,7 @@ class LLMSingleton:
27
25
 
28
26
  if not selected_config:
29
27
  raise ValueError(f"No configuration found for the model: {model_id}")
30
- if not isinstance(selected_config, LLMConfigItem):
31
- selected_config = LLMConfigItem(**selected_config)
28
+
32
29
  LLMSingleton._model = model_id
33
30
 
34
31
  # Typesafe for None values inside the config.
@@ -495,7 +492,7 @@ def generate_config_prompt_template_file(prompt_data_path, config_prompt_templat
495
492
  def generate_config_prompt_givens_file(prompt_data_path, config_prompt_givens_name, artefact_to_mark=None):
496
493
  config_prompt_givens_path = os.path.join(prompt_data_path, config_prompt_givens_name)
497
494
  config = ConfigManager.get_config()
498
- dir_list = ["ara"] + [ext.source_dir for ext in config.ext_code_dirs] + [config.doc_dir] + [config.glossary_dir]
495
+ dir_list = ["ara"] + [ext['source_dir'] for ext in config.ext_code_dirs] + [config.doc_dir] + [config.glossary_dir]
499
496
 
500
497
  print(f"used {dir_list} for prompt givens file listing")
501
498
  generate_markdown_listing(dir_list, config.ara_prompt_given_list_includes, config_prompt_givens_path)
ara_cli/version.py CHANGED
@@ -1,2 +1,2 @@
1
1
  # version.py
2
- __version__ = "0.1.9.86" # fith parameter like .0 for local install test purposes only. official numbers should be 4 digit numbers
2
+ __version__ = "0.1.9.87" # fith parameter like .0 for local install test purposes only. official numbers should be 4 digit numbers
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ara_cli
3
- Version: 0.1.9.86
3
+ Version: 0.1.9.87
4
4
  Summary: Powerful, open source command-line tool for managing, structuring and automating software development artifacts in line with Business-Driven Development (BDD) and AI-assisted processes
5
5
  Description-Content-Type: text/markdown
6
6
  Requires-Dist: litellm
@@ -2,7 +2,7 @@ ara_cli/__init__.py,sha256=0zl7IegxTid26EBGLav_fXZ4CCIV3H5TfAoFQiOHjvg,148
2
2
  ara_cli/__main__.py,sha256=J5DCDLRZ6UcpYwM1-NkjaLo4PTetcSj2dB4HrrftkUw,2064
3
3
  ara_cli/ara_command_action.py,sha256=_LHE2V5hbJxN7ccYiptuPktRfbTnXmQEt_D_FxDBlBY,22456
4
4
  ara_cli/ara_command_parser.py,sha256=I-e9W-QwTIMKMzlHycSlCWCyBFQfiFYvGre1XsDbrFI,20573
5
- ara_cli/ara_config.py,sha256=SgZfQVpqj5JJN4SB0n2IvAH0sKIdS3k1K1Zht2wDywA,8814
5
+ ara_cli/ara_config.py,sha256=KVITofnYlIVyhf50qwUO5fu8vlxjDwRjPyKzqEhEC_M,6982
6
6
  ara_cli/artefact_autofix.py,sha256=WVTiIR-jo4YKmmz4eS3qTFvl45W1YKwAk1XSuz9QX10,20015
7
7
  ara_cli/artefact_creator.py,sha256=0Ory6cB-Ahkw-BDNb8QHnTbp_OHGABdkb9bhwcEdcIc,6063
8
8
  ara_cli/artefact_deleter.py,sha256=Co4wwCH3yW8H9NrOq7_2p5571EeHr0TsfE-H8KqoOfY,1900
@@ -25,13 +25,13 @@ ara_cli/list_filter.py,sha256=qKGwwQsrWe7L5FbdxEbBYD1bbbi8c-RMypjXqXvLbgs,5291
25
25
  ara_cli/output_suppressor.py,sha256=nwiHaQLwabOjMoJOeUESBnZszGMxrQZfJ3N2OvahX7Y,389
26
26
  ara_cli/prompt_chat.py,sha256=kd_OINDQFit6jN04bb7mzgY259JBbRaTaNp9F-webkc,1346
27
27
  ara_cli/prompt_extractor.py,sha256=6xLGd4ZJHDKkamEUQcdRbKM3ilBtxBjp0X2o8wrvHb0,7732
28
- ara_cli/prompt_handler.py,sha256=iulI3A4lHXvVITX7hiVN-pR61bzJmIfoYMGK_aO2Pfs,20248
28
+ ara_cli/prompt_handler.py,sha256=5FoVCNmmzrS4hjHL4qKteQt2A5MIycoZStkJrVL5l_4,20136
29
29
  ara_cli/prompt_rag.py,sha256=ydlhe4CUqz0jdzlY7jBbpKaf_5fjMrAZKnriKea3ZAg,7485
30
30
  ara_cli/run_file_lister.py,sha256=XbrrDTJXp1LFGx9Lv91SNsEHZPP-PyEMBF_P4btjbDA,2360
31
31
  ara_cli/tag_extractor.py,sha256=TGdaQOVnjy25R0zDsAifB67C5oom0Fwo24s0_fr5A_I,3151
32
32
  ara_cli/template_manager.py,sha256=YwrN6AYPpl6ZrW8BVQpVXx8yTRf-oNpJUIKeg4NAggs,6606
33
33
  ara_cli/update_config_prompt.py,sha256=Oy9vNTw6UhDohyTEfSKkqE5ifEMPlmWNYkKHgUrK_pY,4607
34
- ara_cli/version.py,sha256=3fpgzO-uIT17SkvY-eWOWvuwKw3R0Pg3_9_JwMnXP8Q,146
34
+ ara_cli/version.py,sha256=27waJFP_fluwJJNMwbe3_NlHJaeI6oPvAK2CQIhI63w,146
35
35
  ara_cli/artefact_models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
36
  ara_cli/artefact_models/artefact_load.py,sha256=IXzWxP-Q_j_oDGMno0m-OuXCQ7Vd5c_NctshGr4ROBw,621
37
37
  ara_cli/artefact_models/artefact_mapping.py,sha256=8aD0spBjkJ8toMAmFawc6UTUxB6-tEEViZXv2I-r88Q,1874
@@ -134,7 +134,7 @@ ara_cli/templates/specification_breakdown_files/template.technology.exploration.
134
134
  ara_cli/templates/specification_breakdown_files/template.technology.md,sha256=bySiksz-8xtq0Nnj4svqe2MgUftWrVkbK9AcrDUE3KY,952
135
135
  tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
136
136
  tests/test_ara_command_action.py,sha256=JTLqXM9BSMlU33OQgrk_sZnoowFJZKZAx8q-st-wa34,25821
137
- tests/test_ara_config.py,sha256=pvkdPLTzgLkOijil0HaN0mhLC2Rdu4Fu5RfXEyOlRfs,16672
137
+ tests/test_ara_config.py,sha256=gYqBB4Z7lB0PTSZrJRL7ekC0t9HZ_Rq3JF9EnOFmN5U,14280
138
138
  tests/test_artefact_autofix.py,sha256=pApZ-N0dW8Ujt-cNLbgvd4bhiIIK8oXb-saLf6QlA-8,25022
139
139
  tests/test_artefact_fuzzy_search.py,sha256=5Sh3_l9QK8-WHn6JpGPU1b6h4QEnl2JoMq1Tdp2cj1U,1261
140
140
  tests/test_artefact_link_updater.py,sha256=biqbEp2jCOz8giv72hu2P2hDfeJfJ9OrVGdAv5d9cK4,2191
@@ -149,11 +149,12 @@ tests/test_file_classifier.py,sha256=kLWPiePu3F5mkVuI_lK_2QlLh2kXD_Mt2K8KZZ1fAnA
149
149
  tests/test_file_creator.py,sha256=D3G7MbgE0m8JmZihxnTryxLco6iZdbV--2CGc0L20FM,2109
150
150
  tests/test_file_lister.py,sha256=Q9HwhKKx540EPzTmfzOCnvtAgON0aMmpJE2eOe1J3EA,4324
151
151
  tests/test_list_filter.py,sha256=fJA3d_SdaOAUkE7jn68MOVS0THXGghy1fye_64Zvo1U,7964
152
+ tests/test_prompt_handler.py,sha256=7V_AwXd2co1krnx5RKZRK-hqXS50nq77mX-Yx_QO0w0,13084
152
153
  tests/test_tag_extractor.py,sha256=nSiAYlTKZ7TLAOtcJpwK5zTWHhFYU0tI5xKnivLc1dU,2712
153
154
  tests/test_template_manager.py,sha256=q-LMHRG4rHkD6ON6YW4cpZxUx9hul6Or8wVVRC2kb-8,4099
154
155
  tests/test_update_config_prompt.py,sha256=xsqj1WTn4BsG5Q2t-sNPfu7EoMURFcS-hfb5VSXUnJc,6765
155
- ara_cli-0.1.9.86.dist-info/METADATA,sha256=EN5cqYvzoOF1lBV8pbnV8tGacSKIH1nikcg_ePdbChk,6739
156
- ara_cli-0.1.9.86.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
157
- ara_cli-0.1.9.86.dist-info/entry_points.txt,sha256=v4h7MzysTgSIDYfEo3oj4Kz_8lzsRa3hq-KJHEcLVX8,45
158
- ara_cli-0.1.9.86.dist-info/top_level.txt,sha256=WM4cLHT5DYUaWzLtRj-gu3yVNFpGQ6lLRI3FMmC-38I,14
159
- ara_cli-0.1.9.86.dist-info/RECORD,,
156
+ ara_cli-0.1.9.87.dist-info/METADATA,sha256=WKN4uhNXDHMDwwlxkCW5KbBV_2cbeixRkQYsLZNBdLY,6739
157
+ ara_cli-0.1.9.87.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
158
+ ara_cli-0.1.9.87.dist-info/entry_points.txt,sha256=v4h7MzysTgSIDYfEo3oj4Kz_8lzsRa3hq-KJHEcLVX8,45
159
+ ara_cli-0.1.9.87.dist-info/top_level.txt,sha256=WM4cLHT5DYUaWzLtRj-gu3yVNFpGQ6lLRI3FMmC-38I,14
160
+ ara_cli-0.1.9.87.dist-info/RECORD,,
tests/test_ara_config.py CHANGED
@@ -1,169 +1,130 @@
1
1
  import os
2
2
  import json
3
3
  import pytest
4
- from unittest.mock import patch, mock_open, MagicMock, call
5
- from tempfile import TemporaryDirectory
6
- from pydantic import ValidationError
4
+ from unittest.mock import patch, mock_open, MagicMock
7
5
  import sys
8
6
  from io import StringIO
7
+ from pydantic import ValidationError
9
8
 
9
+ # Assuming the test file is structured to import from the production code module
10
10
  from ara_cli.ara_config import (
11
- ensure_directory_exists,
12
- read_data,
11
+ ensure_directory_exists,
12
+ read_data,
13
13
  save_data,
14
- ARAconfig,
15
- ConfigManager,
14
+ ARAconfig,
15
+ ConfigManager,
16
16
  DEFAULT_CONFIG_LOCATION,
17
17
  LLMConfigItem,
18
- ExtCodeDirItem,
19
18
  handle_unrecognized_keys,
20
- fix_llm_temperatures,
21
- validate_and_fix_config_data
22
19
  )
23
20
 
24
21
 
25
22
  @pytest.fixture
26
23
  def default_config_data():
24
+ """Provides the default configuration as a dictionary."""
27
25
  return ARAconfig().model_dump()
28
26
 
29
27
 
30
28
  @pytest.fixture
31
29
  def valid_config_dict():
30
+ """A valid, non-default configuration dictionary for testing."""
32
31
  return {
33
- "ext_code_dirs": [
34
- {"source_dir": "./src"},
35
- {"source_dir": "./tests"}
36
- ],
37
- "glossary_dir": "./glossary",
38
- "doc_dir": "./docs",
39
- "local_prompt_templates_dir": "./ara/.araconfig",
40
- "custom_prompt_templates_subdir": "custom-prompt-modules",
41
- "local_ara_templates_dir": "./ara/.araconfig/templates/",
42
- "ara_prompt_given_list_includes": ["*.py", "*.md"],
32
+ "ext_code_dirs": [{"source_dir": "./app"}],
33
+ "glossary_dir": "./custom_glossary",
34
+ "doc_dir": "./custom_docs",
35
+ "local_prompt_templates_dir": "./custom_prompts",
36
+ "custom_prompt_templates_subdir": "custom_subdir",
37
+ "local_ara_templates_dir": "./custom_templates/",
38
+ "ara_prompt_given_list_includes": ["*.py", "*.md", "*.json"],
43
39
  "llm_config": {
44
- "gpt-4o": {
40
+ "gpt-4o-custom": {
45
41
  "provider": "openai",
46
42
  "model": "openai/gpt-4o",
47
- "temperature": 0.8,
48
- "max_tokens": 16384
43
+ "temperature": 0.5,
44
+ "max_tokens": 4096
49
45
  }
50
46
  },
51
- "default_llm": "gpt-4o"
47
+ "default_llm": "gpt-4o-custom"
52
48
  }
53
49
 
54
50
 
55
51
  @pytest.fixture
56
52
  def corrupted_config_dict():
53
+ """A config dictionary with various type errors to test validation and fixing."""
57
54
  return {
58
- "ext_code_dirs": "should_be_a_list", # Wrong type
59
- "glossary_dir": 123, # Should be string
55
+ "ext_code_dirs": "should_be_a_list",
56
+ "glossary_dir": 123,
60
57
  "llm_config": {
61
- "gpt-4o": {
62
- "provider": "openai",
63
- "model": "openai/gpt-4o",
64
- "temperature": "should_be_float", # Wrong type
65
- "max_tokens": "16384" # Should be int
58
+ "bad-model": {
59
+ "provider": "test",
60
+ "model": "test/model",
61
+ "temperature": "not_a_float"
66
62
  }
67
- }
63
+ },
64
+ "default_llm": 999
68
65
  }
69
66
 
70
67
 
71
68
  @pytest.fixture(autouse=True)
72
69
  def reset_config_manager():
73
- """Reset ConfigManager before each test"""
70
+ """Ensures a clean state for each test by resetting the singleton and caches."""
74
71
  ConfigManager.reset()
75
72
  yield
76
73
  ConfigManager.reset()
77
74
 
75
+ # --- Test Pydantic Models ---
78
76
 
79
77
  class TestLLMConfigItem:
80
78
  def test_valid_temperature(self):
81
- config = LLMConfigItem(
82
- provider="openai",
83
- model="gpt-4",
84
- temperature=0.7
85
- )
79
+ """Tests that a valid temperature is accepted."""
80
+ config = LLMConfigItem(provider="test", model="test/model", temperature=0.7)
86
81
  assert config.temperature == 0.7
87
82
 
88
- def test_invalid_temperature_raises_validation_error(self):
89
- # The Field constraint prevents invalid temperatures from being created
90
- with pytest.raises(ValidationError) as exc_info:
91
- LLMConfigItem(
92
- provider="openai",
93
- model="gpt-4",
94
- temperature=1.5
95
- )
96
- assert "less than or equal to 1" in str(exc_info.value)
97
-
98
- def test_negative_temperature_raises_validation_error(self):
99
- # The Field constraint prevents negative temperatures
100
- with pytest.raises(ValidationError) as exc_info:
101
- LLMConfigItem(
102
- provider="openai",
103
- model="gpt-4",
104
- temperature=-0.5
105
- )
106
- assert "greater than or equal to 0" in str(exc_info.value)
107
-
108
- def test_temperature_validator_with_dict_input(self):
109
- # Test the validator through dict input (simulating JSON load)
110
- # This tests the fix_llm_temperatures function behavior
111
- data = {
112
- "provider": "openai",
113
- "model": "gpt-4",
114
- "temperature": 0.8
115
- }
116
- config = LLMConfigItem(**data)
117
- assert config.temperature == 0.8
118
-
83
+ def test_invalid_temperature_too_high_raises_error(self):
84
+ """Tests that temperature > 1.0 raises a ValidationError."""
85
+ with pytest.raises(ValidationError, match="Input should be less than or equal to 1"):
86
+ LLMConfigItem(provider="test", model="test/model", temperature=1.5)
119
87
 
120
- class TestExtCodeDirItem:
121
- def test_create_ext_code_dir_item(self):
122
- item = ExtCodeDirItem(source_dir="./src")
123
- assert item.source_dir == "./src"
88
+ def test_invalid_temperature_too_low_raises_error(self):
89
+ """Tests that temperature < 0.0 raises a ValidationError."""
90
+ with pytest.raises(ValidationError, match="Input should be greater than or equal to 0"):
91
+ LLMConfigItem(provider="test", model="test/model", temperature=-0.5)
124
92
 
125
93
 
126
94
  class TestARAconfig:
127
- def test_default_values(self):
95
+ def test_default_values_are_correct(self):
96
+ """Tests that the model initializes with correct default values."""
128
97
  config = ARAconfig()
129
- assert len(config.ext_code_dirs) == 2
130
- assert config.ext_code_dirs[0].source_dir == "./src"
131
- assert config.ext_code_dirs[1].source_dir == "./tests"
98
+ assert config.ext_code_dirs == [{"source_dir": "./src"}, {"source_dir": "./tests"}]
132
99
  assert config.glossary_dir == "./glossary"
133
100
  assert config.default_llm == "gpt-4o"
134
-
135
- def test_forbid_extra_fields(self):
136
- with pytest.raises(ValidationError) as exc_info:
137
- ARAconfig(unknown_field="value")
138
- assert "Extra inputs are not permitted" in str(exc_info.value)
101
+ assert "gpt-4o" in config.llm_config
139
102
 
140
103
  @patch('sys.stdout', new_callable=StringIO)
141
- def test_check_critical_fields_empty_list(self, mock_stdout):
104
+ def test_check_critical_fields_with_empty_list_reverts_to_default(self, mock_stdout):
105
+ """Tests that an empty list for a critical field is reverted to its default."""
142
106
  config = ARAconfig(ext_code_dirs=[])
143
107
  assert len(config.ext_code_dirs) == 2
144
- assert "Warning: Value for 'ext_code_dirs' is missing or empty." in mock_stdout.getvalue()
108
+ assert config.ext_code_dirs[0] == {"source_dir": "./src"}
109
+ assert "Warning: Value for 'ext_code_dirs' is missing or empty. Using default." in mock_stdout.getvalue()
145
110
 
146
111
  @patch('sys.stdout', new_callable=StringIO)
147
- def test_check_critical_fields_empty_string(self, mock_stdout):
112
+ def test_check_critical_fields_with_empty_string_reverts_to_default(self, mock_stdout):
113
+ """Tests that an empty string for a critical field is reverted to its default."""
148
114
  config = ARAconfig(glossary_dir="")
149
115
  assert config.glossary_dir == "./glossary"
150
- assert "Warning: Value for 'glossary_dir' is missing or empty." in mock_stdout.getvalue()
151
-
152
- @patch('sys.stdout', new_callable=StringIO)
153
- def test_check_critical_fields_whitespace_string(self, mock_stdout):
154
- config = ARAconfig(local_prompt_templates_dir=" ")
155
- assert config.local_prompt_templates_dir == "./ara/.araconfig"
156
- assert "Warning: Value for 'local_prompt_templates_dir' is missing or empty." in mock_stdout.getvalue()
116
+ assert "Warning: Value for 'glossary_dir' is missing or empty. Using default." in mock_stdout.getvalue()
157
117
 
118
+ # --- Test Helper Functions ---
158
119
 
159
120
  class TestEnsureDirectoryExists:
160
121
  @patch('sys.stdout', new_callable=StringIO)
161
122
  @patch("os.makedirs")
162
123
  @patch("ara_cli.ara_config.exists", return_value=False)
163
- def test_directory_does_not_exist(self, mock_exists, mock_makedirs, mock_stdout):
164
- directory = "/some/non/existent/directory"
165
- # Clear the cache before test
124
+ def test_directory_creation_when_not_exists(self, mock_exists, mock_makedirs, mock_stdout):
125
+ """Tests that a directory is created if it doesn't exist."""
166
126
  ensure_directory_exists.cache_clear()
127
+ directory = "/tmp/new/dir"
167
128
  result = ensure_directory_exists(directory)
168
129
 
169
130
  mock_exists.assert_called_once_with(directory)
@@ -173,10 +134,10 @@ class TestEnsureDirectoryExists:
173
134
 
174
135
  @patch("os.makedirs")
175
136
  @patch("ara_cli.ara_config.exists", return_value=True)
176
- def test_directory_exists(self, mock_exists, mock_makedirs):
177
- directory = "/some/existent/directory"
178
- # Clear the cache before test
137
+ def test_directory_no_creation_when_exists(self, mock_exists, mock_makedirs):
138
+ """Tests that a directory is not created if it already exists."""
179
139
  ensure_directory_exists.cache_clear()
140
+ directory = "/tmp/existing/dir"
180
141
  result = ensure_directory_exists(directory)
181
142
 
182
143
  mock_exists.assert_called_once_with(directory)
@@ -186,134 +147,37 @@ class TestEnsureDirectoryExists:
186
147
 
187
148
  class TestHandleUnrecognizedKeys:
188
149
  @patch('sys.stdout', new_callable=StringIO)
189
- def test_handle_unrecognized_keys(self, mock_stdout):
190
- data = {
191
- "ext_code_dirs": [],
192
- "glossary_dir": "./glossary",
193
- "unknown_key": "value"
194
- }
195
- known_fields = {"ext_code_dirs", "glossary_dir"}
196
-
197
- result = handle_unrecognized_keys(data, known_fields)
198
-
199
- assert "unknown_key" not in result
200
- assert "ext_code_dirs" in result
201
- assert "glossary_dir" in result
202
- assert "Warning: unknown_key is not recognized as a valid configuration option." in mock_stdout.getvalue()
203
-
204
- def test_handle_no_unrecognized_keys(self):
205
- data = {
206
- "ext_code_dirs": [],
207
- "glossary_dir": "./glossary"
208
- }
209
- known_fields = {"ext_code_dirs", "glossary_dir"}
210
-
211
- result = handle_unrecognized_keys(data, known_fields)
212
- assert result == data
213
-
214
-
215
- class TestFixLLMTemperatures:
216
- @patch('sys.stdout', new_callable=StringIO)
217
- def test_fix_invalid_temperature_too_high(self, mock_stdout):
218
- data = {
219
- "llm_config": {
220
- "gpt-4o": {
221
- "temperature": 1.5
222
- }
223
- }
224
- }
225
-
226
- result = fix_llm_temperatures(data)
227
-
228
- assert result["llm_config"]["gpt-4o"]["temperature"] == 0.8
229
- assert "Warning: Temperature for model 'gpt-4o' is outside the 0.0 to 1.0 range" in mock_stdout.getvalue()
230
-
231
- @patch('sys.stdout', new_callable=StringIO)
232
- def test_fix_invalid_temperature_too_low(self, mock_stdout):
233
- data = {
234
- "llm_config": {
235
- "gpt-4o": {
236
- "temperature": -0.5
237
- }
238
- }
239
- }
240
-
241
- result = fix_llm_temperatures(data)
150
+ def test_removes_unrecognized_keys_and_warns(self, mock_stdout):
151
+ """Tests that unknown keys are removed and a warning is printed."""
152
+ data = {"glossary_dir": "./glossary", "unknown_key": "some_value"}
153
+ cleaned_data = handle_unrecognized_keys(data)
242
154
 
243
- assert result["llm_config"]["gpt-4o"]["temperature"] == 0.8
244
- assert "Warning: Temperature for model 'gpt-4o' is outside the 0.0 to 1.0 range" in mock_stdout.getvalue()
245
-
246
- def test_valid_temperature_not_changed(self):
247
- data = {
248
- "llm_config": {
249
- "gpt-4o": {
250
- "temperature": 0.7
251
- }
252
- }
253
- }
254
-
255
- result = fix_llm_temperatures(data)
256
- assert result["llm_config"]["gpt-4o"]["temperature"] == 0.7
155
+ assert "unknown_key" not in cleaned_data
156
+ assert "glossary_dir" in cleaned_data
157
+ assert "Warning: Unrecognized configuration key 'unknown_key' will be ignored." in mock_stdout.getvalue()
257
158
 
258
- def test_no_llm_config(self):
259
- data = {"other_field": "value"}
260
- result = fix_llm_temperatures(data)
261
- assert result == data
262
-
263
-
264
- class TestValidateAndFixConfigData:
265
159
  @patch('sys.stdout', new_callable=StringIO)
266
- @patch("builtins.open")
267
- def test_valid_json_with_unrecognized_keys(self, mock_file, mock_stdout, valid_config_dict):
268
- valid_config_dict["unknown_key"] = "value"
269
- mock_file.return_value = mock_open(read_data=json.dumps(valid_config_dict))()
270
-
271
- result = validate_and_fix_config_data("config.json")
160
+ def test_no_action_for_valid_data(self, mock_stdout):
161
+ """Tests that no changes are made when there are no unrecognized keys."""
162
+ data = {"glossary_dir": "./glossary", "doc_dir": "./docs"}
163
+ cleaned_data = handle_unrecognized_keys(data)
272
164
 
273
- assert "unknown_key" not in result
274
- assert "ext_code_dirs" in result
275
- assert "Warning: unknown_key is not recognized as a valid configuration option." in mock_stdout.getvalue()
276
-
277
- @patch('sys.stdout', new_callable=StringIO)
278
- @patch("builtins.open", mock_open(read_data="invalid json"))
279
- def test_invalid_json(self, mock_stdout):
280
- result = validate_and_fix_config_data("config.json")
281
-
282
- assert result == {}
283
- assert "Error: Invalid JSON in configuration file:" in mock_stdout.getvalue()
284
- assert "Creating new configuration with defaults..." in mock_stdout.getvalue()
285
-
286
- @patch('sys.stdout', new_callable=StringIO)
287
- @patch("builtins.open", side_effect=IOError("File not found"))
288
- def test_file_read_error(self, mock_file, mock_stdout):
289
- result = validate_and_fix_config_data("config.json")
290
-
291
- assert result == {}
292
- assert "Error reading configuration file: File not found" in mock_stdout.getvalue()
293
-
294
- @patch('sys.stdout', new_callable=StringIO)
295
- @patch("builtins.open")
296
- def test_fix_invalid_temperatures(self, mock_file, mock_stdout, valid_config_dict):
297
- valid_config_dict["llm_config"]["gpt-4o"]["temperature"] = 2.0
298
- mock_file.return_value = mock_open(read_data=json.dumps(valid_config_dict))()
299
-
300
- result = validate_and_fix_config_data("config.json")
301
-
302
- assert result["llm_config"]["gpt-4o"]["temperature"] == 0.8
303
- assert "Warning: Temperature for model 'gpt-4o' is outside the 0.0 to 1.0 range" in mock_stdout.getvalue()
165
+ assert cleaned_data == data
166
+ assert mock_stdout.getvalue() == ""
304
167
 
168
+ # --- Test Core I/O and Logic ---
305
169
 
306
170
  class TestSaveData:
307
171
  @patch("builtins.open", new_callable=mock_open)
308
- def test_save_data(self, mock_file, default_config_data):
172
+ def test_save_data_writes_correct_json(self, mock_file, default_config_data):
173
+ """Tests that the config is correctly serialized to a JSON file."""
309
174
  config = ARAconfig()
310
-
311
175
  save_data("config.json", config)
312
-
176
+
313
177
  mock_file.assert_called_once_with("config.json", "w", encoding="utf-8")
314
- # Check that json.dump was called with correct data
315
178
  handle = mock_file()
316
179
  written_data = ''.join(call.args[0] for call in handle.write.call_args_list)
180
+
317
181
  assert json.loads(written_data) == default_config_data
318
182
 
319
183
 
@@ -322,26 +186,53 @@ class TestReadData:
322
186
  @patch('ara_cli.ara_config.save_data')
323
187
  @patch('ara_cli.ara_config.ensure_directory_exists')
324
188
  @patch('ara_cli.ara_config.exists', return_value=False)
325
- def test_file_does_not_exist_creates_default(self, mock_exists, mock_ensure_dir, mock_save, mock_stdout):
189
+ def test_file_not_found_creates_default_and_exits(self, mock_exists, mock_ensure_dir, mock_save, mock_stdout):
190
+ """Tests that a default config is created and the program exits if no config file is found."""
326
191
  with pytest.raises(SystemExit) as exc_info:
327
- read_data.cache_clear() # Clear cache
192
+ read_data.cache_clear()
328
193
  read_data("config.json")
329
-
194
+
330
195
  assert exc_info.value.code == 0
196
+ mock_ensure_dir.assert_called_once_with(os.path.dirname("config.json"))
331
197
  mock_save.assert_called_once()
332
- assert "ara-cli configuration file 'config.json' created with default configuration." in mock_stdout.getvalue()
198
+
199
+ output = mock_stdout.getvalue()
200
+ assert "Configuration file not found. Creating a default one at 'config.json'." in output
201
+ assert "Please review the default configuration and re-run your command." in output
333
202
 
334
203
  @patch('ara_cli.ara_config.save_data')
335
204
  @patch('builtins.open')
336
205
  @patch('ara_cli.ara_config.ensure_directory_exists')
337
206
  @patch('ara_cli.ara_config.exists', return_value=True)
338
- def test_file_exists_valid_config(self, mock_exists, mock_ensure_dir, mock_file, mock_save, valid_config_dict):
339
- mock_file.return_value = mock_open(read_data=json.dumps(valid_config_dict))()
340
- read_data.cache_clear() # Clear cache
207
+ def test_valid_config_is_loaded_and_resaved(self, mock_exists, mock_ensure_dir, mock_open_func, mock_save, valid_config_dict):
208
+ """Tests that a valid config is loaded correctly and re-saved (to clean it)."""
209
+ m = mock_open(read_data=json.dumps(valid_config_dict))
210
+ mock_open_func.return_value = m()
211
+ read_data.cache_clear()
212
+
213
+ result = read_data("config.json")
214
+
215
+ assert isinstance(result, ARAconfig)
216
+ assert result.default_llm == "gpt-4o-custom"
217
+ mock_save.assert_called_once()
218
+
219
+ @patch('sys.stdout', new_callable=StringIO)
220
+ @patch('ara_cli.ara_config.save_data')
221
+ @patch('builtins.open', new_callable=mock_open, read_data="this is not json")
222
+ @patch('ara_cli.ara_config.ensure_directory_exists')
223
+ @patch('ara_cli.ara_config.exists', return_value=True)
224
+ def test_invalid_json_creates_default_config(self, mock_exists, mock_ensure_dir, mock_open_func, mock_save, mock_stdout):
225
+ """Tests that a JSON decoding error results in a new default configuration."""
226
+ read_data.cache_clear()
341
227
 
342
228
  result = read_data("config.json")
343
229
 
344
230
  assert isinstance(result, ARAconfig)
231
+ assert result.default_llm == "gpt-4o" # Should be the default config
232
+
233
+ output = mock_stdout.getvalue()
234
+ assert "Error: Invalid JSON in configuration file" in output
235
+ assert "Creating a new configuration with defaults..." in output
345
236
  mock_save.assert_called_once()
346
237
 
347
238
  @patch('sys.stdout', new_callable=StringIO)
@@ -349,95 +240,97 @@ class TestReadData:
349
240
  @patch('builtins.open')
350
241
  @patch('ara_cli.ara_config.ensure_directory_exists')
351
242
  @patch('ara_cli.ara_config.exists', return_value=True)
352
- def test_file_exists_with_validation_error(self, mock_exists, mock_ensure_dir, mock_file,
353
- mock_save, mock_stdout, corrupted_config_dict):
354
- mock_file.return_value = mock_open(read_data=json.dumps(corrupted_config_dict))()
355
- read_data.cache_clear() # Clear cache
243
+ def test_config_with_validation_errors_is_fixed(self, mock_exists, mock_ensure_dir, mock_open_func, mock_save, mock_stdout, corrupted_config_dict):
244
+ """Tests that a config with invalid fields is automatically corrected to defaults."""
245
+ m = mock_open(read_data=json.dumps(corrupted_config_dict))
246
+ mock_open_func.return_value = m()
247
+ read_data.cache_clear()
356
248
 
249
+ defaults = ARAconfig()
357
250
  result = read_data("config.json")
358
-
251
+
359
252
  assert isinstance(result, ARAconfig)
253
+ assert result.ext_code_dirs == defaults.ext_code_dirs
254
+ assert result.glossary_dir == defaults.glossary_dir
255
+ assert result.llm_config == defaults.llm_config
256
+ assert result.default_llm == defaults.default_llm
257
+
360
258
  output = mock_stdout.getvalue()
361
- # Check for any error message related to type conversion
362
- assert ("Error reading configuration file:" in output or
363
- "ValidationError:" in output)
364
- mock_save.assert_called()
259
+ assert "--- Configuration Error Detected ---" in output
260
+ assert "-> Field 'ext_code_dirs' is invalid and will be reverted to its default value." in output
261
+ assert "-> Field 'glossary_dir' is invalid and will be reverted to its default value." in output
262
+ assert "-> Field 'llm_config' is invalid and will be reverted to its default value." in output
263
+ assert "Configuration has been corrected and saved" in output
264
+
265
+ mock_save.assert_called_once_with("config.json", result)
365
266
 
366
267
  @patch('sys.stdout', new_callable=StringIO)
367
268
  @patch('ara_cli.ara_config.save_data')
368
269
  @patch('builtins.open')
369
270
  @patch('ara_cli.ara_config.ensure_directory_exists')
370
271
  @patch('ara_cli.ara_config.exists', return_value=True)
371
- def test_preserve_valid_fields_on_error(self, mock_exists, mock_ensure_dir, mock_file,
372
- mock_save, mock_stdout):
373
- partial_valid_config = {
374
- "glossary_dir": "./custom/glossary",
375
- "ext_code_dirs": "invalid", # This will cause validation error
376
- "doc_dir": "./custom/docs"
272
+ def test_preserves_valid_fields_when_fixing_errors(self, mock_exists, mock_ensure_dir, mock_open_func, mock_save, mock_stdout):
273
+ """Tests that valid, non-default values are preserved during a fix."""
274
+ mixed_config = {
275
+ "glossary_dir": "./my-custom-glossary", # Valid, non-default
276
+ "default_llm": 12345, # Invalid type
277
+ "unrecognized_key": "will_be_ignored" # Unrecognized
377
278
  }
279
+ m = mock_open(read_data=json.dumps(mixed_config))
280
+ mock_open_func.return_value = m()
281
+ read_data.cache_clear()
378
282
 
379
- mock_file.return_value = mock_open(read_data=json.dumps(partial_valid_config))()
380
- read_data.cache_clear() # Clear cache
381
-
283
+ defaults = ARAconfig()
382
284
  result = read_data("config.json")
383
-
384
- # The implementation actually preserves the invalid value
385
- # This is the actual behavior based on the error message
386
- assert isinstance(result, ARAconfig)
387
- assert result.ext_code_dirs == "invalid" # The invalid value is preserved
388
- assert result.glossary_dir == "./custom/glossary"
389
- assert result.doc_dir == "./custom/docs"
390
-
285
+
286
+ assert result.glossary_dir == "./my-custom-glossary"
287
+ assert result.default_llm == defaults.default_llm
288
+
391
289
  output = mock_stdout.getvalue()
392
- assert "ValidationError:" in output
393
- assert "Correcting configuration with default values..." in output
290
+ assert "Warning: Unrecognized configuration key 'unrecognized_key' will be ignored." in output
291
+ assert "-> Field 'default_llm' is invalid" in output
292
+ assert "-> Field 'glossary_dir' is invalid" not in output
394
293
 
294
+ mock_save.assert_called_once()
295
+ saved_config = mock_save.call_args[0][1]
296
+ assert saved_config.glossary_dir == "./my-custom-glossary"
297
+ assert saved_config.default_llm == defaults.default_llm
298
+
299
+ # --- Test Singleton Manager ---
395
300
 
396
301
  class TestConfigManager:
397
302
  @patch('ara_cli.ara_config.read_data')
398
- def test_get_config_singleton(self, mock_read):
399
- mock_config = MagicMock(spec=ARAconfig)
400
- mock_read.return_value = mock_config
303
+ def test_get_config_is_singleton(self, mock_read):
304
+ """Tests that get_config returns the same instance on subsequent calls."""
305
+ mock_read.return_value = MagicMock(spec=ARAconfig)
401
306
 
402
- # First call
403
307
  config1 = ConfigManager.get_config()
404
- assert config1 == mock_config
405
- mock_read.assert_called_once()
406
-
407
- # Second call should return cached instance
408
308
  config2 = ConfigManager.get_config()
409
- assert config2 == config1
410
- mock_read.assert_called_once() # Still only called once
309
+
310
+ assert config1 is config2
311
+ mock_read.assert_called_once()
411
312
 
412
313
  @patch('ara_cli.ara_config.read_data')
413
- @patch('ara_cli.ara_config.makedirs')
414
- @patch('ara_cli.ara_config.exists', return_value=False)
415
- def test_get_config_creates_directory_if_not_exists(self, mock_exists, mock_makedirs, mock_read):
314
+ def test_reset_clears_instance_and_caches(self, mock_read):
315
+ """Tests that the reset method clears the instance and underlying caches."""
416
316
  mock_read.return_value = MagicMock(spec=ARAconfig)
417
-
418
- ConfigManager.get_config("./custom/config.json")
419
- mock_makedirs.assert_called_once_with("./custom")
420
317
 
421
- @patch('ara_cli.ara_config.read_data')
422
- def test_reset(self, mock_read):
423
- mock_config = MagicMock(spec=ARAconfig)
424
- mock_read.return_value = mock_config
425
-
426
- # Get config
427
- config1 = ConfigManager.get_config()
428
- assert ConfigManager._config_instance is not None
318
+ ConfigManager.get_config()
319
+ mock_read.assert_called_once()
429
320
 
430
- # Reset
431
321
  ConfigManager.reset()
432
322
  assert ConfigManager._config_instance is None
433
323
  mock_read.cache_clear.assert_called_once()
434
324
 
325
+ ConfigManager.get_config()
326
+ assert mock_read.call_count == 2 # Called again after reset
327
+
435
328
  @patch('ara_cli.ara_config.read_data')
436
- def test_custom_filepath(self, mock_read):
437
- custom_path = "./custom/ara_config.json"
438
- mock_config = MagicMock(spec=ARAconfig)
439
- mock_read.return_value = mock_config
329
+ def test_get_config_with_custom_filepath(self, mock_read):
330
+ """Tests that get_config can be called with a custom file path."""
331
+ mock_read.return_value = MagicMock(spec=ARAconfig)
332
+ custom_path = "/custom/path/config.json"
333
+
334
+ ConfigManager.get_config(custom_path)
440
335
 
441
- config = ConfigManager.get_config(custom_path)
442
- mock_read.assert_called_once_with(custom_path)
443
- assert config == mock_config
336
+ mock_read.assert_called_once_with(custom_path)
@@ -0,0 +1,303 @@
1
+ import pytest
2
+ import os
3
+ import shutil
4
+ import base64
5
+ from unittest.mock import patch, MagicMock, mock_open, call
6
+ from pathlib import Path
7
+
8
+ from ara_cli import prompt_handler
9
+ from ara_cli.ara_config import ARAconfig, LLMConfigItem
10
+
11
+ @pytest.fixture
12
+ def mock_config():
13
+ """Mocks a standard ARAconfig object for testing."""
14
+ config = ARAconfig(
15
+ ext_code_dirs=[{"source_dir": "./src"}],
16
+ glossary_dir="./glossary",
17
+ doc_dir="./docs",
18
+ local_prompt_templates_dir="./ara/.araconfig",
19
+ custom_prompt_templates_subdir="custom-prompt-modules",
20
+ ara_prompt_given_list_includes=["*.py"],
21
+ llm_config={
22
+ "gpt-4o": LLMConfigItem(provider="openai", model="openai/gpt-4o", temperature=0.8, max_tokens=1024),
23
+ "o3-mini": LLMConfigItem(provider="openai", model="openai/o3-mini", temperature=0.9, max_tokens=2048),
24
+ },
25
+ default_llm="gpt-4o"
26
+ )
27
+ return config
28
+
29
+ @pytest.fixture
30
+ def mock_config_manager(mock_config):
31
+ """Patches ConfigManager to ensure it always returns the mock_config."""
32
+ with patch('ara_cli.ara_config.ConfigManager.get_config') as mock_get_config:
33
+ mock_get_config.return_value = mock_config
34
+ yield mock_get_config
35
+
36
+ @pytest.fixture(autouse=True)
37
+ def reset_singleton():
38
+ """Resets the LLMSingleton before each test for isolation."""
39
+ prompt_handler.LLMSingleton._instance = None
40
+ prompt_handler.LLMSingleton._model = None
41
+ yield
42
+
43
+ class TestLLMSingleton:
44
+ """Tests the behavior of the LLMSingleton class."""
45
+
46
+ def test_get_instance_creates_with_default_model(self, mock_config_manager):
47
+ instance = prompt_handler.LLMSingleton.get_instance()
48
+ assert instance is not None
49
+ assert prompt_handler.LLMSingleton.get_model() == "gpt-4o"
50
+ assert instance.config_parameters['temperature'] == 0.8
51
+
52
+ def test_get_instance_creates_with_first_model_if_no_default(self, mock_config_manager, mock_config):
53
+ mock_config.default_llm = None
54
+ instance = prompt_handler.LLMSingleton.get_instance()
55
+ assert instance is not None
56
+ assert prompt_handler.LLMSingleton.get_model() == "gpt-4o"
57
+
58
+ def test_get_instance_returns_same_instance(self, mock_config_manager):
59
+ instance1 = prompt_handler.LLMSingleton.get_instance()
60
+ instance2 = prompt_handler.LLMSingleton.get_instance()
61
+ assert instance1 is instance2
62
+
63
+ def test_set_model_switches_model(self, mock_config_manager):
64
+ initial_instance = prompt_handler.LLMSingleton.get_instance()
65
+ assert prompt_handler.LLMSingleton.get_model() == "gpt-4o"
66
+
67
+ with patch('builtins.print') as mock_print:
68
+ new_instance = prompt_handler.LLMSingleton.set_model("o3-mini")
69
+ mock_print.assert_called_with("Language model switched to 'o3-mini'")
70
+
71
+ assert prompt_handler.LLMSingleton.get_model() == "o3-mini"
72
+ assert new_instance.config_parameters['temperature'] == 0.9
73
+ assert initial_instance is not new_instance
74
+
75
+ def test_set_model_to_invalid_raises_error(self, mock_config_manager):
76
+ with pytest.raises(ValueError, match="No configuration found for the model: invalid-model"):
77
+ prompt_handler.LLMSingleton.set_model("invalid-model")
78
+
79
+ def test_get_model_initializes_if_needed(self, mock_config_manager):
80
+ assert prompt_handler.LLMSingleton._instance is None
81
+ model = prompt_handler.LLMSingleton.get_model()
82
+ assert model == "gpt-4o"
83
+ assert prompt_handler.LLMSingleton._instance is not None
84
+
85
+ class TestFileIO:
86
+ """Tests file I/O helper functions."""
87
+
88
+ def test_write_and_read_string_from_file(self, tmp_path):
89
+ file_path = tmp_path / "test.txt"
90
+ test_string = "Hello World"
91
+
92
+ prompt_handler.write_string_to_file(file_path, test_string, 'w')
93
+
94
+ content = prompt_handler.read_string_from_file(file_path)
95
+ assert test_string in content
96
+
97
+ content_get = prompt_handler.get_file_content(file_path)
98
+ assert content == content_get
99
+
100
+
101
+ class TestCoreLogic:
102
+ """Tests functions related to the main business logic."""
103
+
104
+ @patch('ara_cli.prompt_handler.litellm.completion')
105
+ @patch('ara_cli.prompt_handler.LLMSingleton.get_instance')
106
+ def test_send_prompt(self, mock_get_instance, mock_completion, mock_config):
107
+ mock_llm_instance = MagicMock()
108
+ mock_llm_instance.config_parameters = mock_config.llm_config['gpt-4o'].model_dump()
109
+ mock_get_instance.return_value = mock_llm_instance
110
+
111
+ mock_chunk = MagicMock()
112
+ mock_chunk.choices[0].delta.content = "test chunk"
113
+ mock_completion.return_value = [mock_chunk]
114
+
115
+ prompt = [{"role": "user", "content": "A test"}]
116
+
117
+ result = list(prompt_handler.send_prompt(prompt))
118
+
119
+ expected_params = mock_config.llm_config['gpt-4o'].model_dump(exclude_none=True)
120
+ del expected_params['provider']
121
+
122
+ mock_completion.assert_called_once_with(
123
+ messages=prompt,
124
+ stream=True,
125
+ **expected_params
126
+ )
127
+ assert len(result) == 1
128
+ assert result[0].choices[0].delta.content == "test chunk"
129
+
130
+ @patch('ara_cli.prompt_handler.send_prompt')
131
+ def test_describe_image(self, mock_send_prompt, tmp_path):
132
+ fake_image_path = tmp_path / "test.png"
133
+ fake_image_content = b"fakeimagedata"
134
+ fake_image_path.write_bytes(fake_image_content)
135
+
136
+ mock_send_prompt.return_value = iter([])
137
+
138
+ prompt_handler.describe_image(fake_image_path)
139
+
140
+ mock_send_prompt.assert_called_once()
141
+ called_args = mock_send_prompt.call_args[0][0]
142
+
143
+ assert len(called_args) == 1
144
+ message_content = called_args[0]['content']
145
+ assert isinstance(message_content, list)
146
+ assert message_content[0]['type'] == 'text'
147
+ assert message_content[1]['type'] == 'image_url'
148
+
149
+ encoded_image = base64.b64encode(fake_image_content).decode('utf-8')
150
+ expected_url = f"data:image/png;base64,{encoded_image}"
151
+ assert message_content[1]['image_url']['url'] == expected_url
152
+
153
+ @patch('ara_cli.prompt_handler.Classifier.get_sub_directory', return_value="test_classifier")
154
+ def test_append_headings(self, mock_get_sub, tmp_path):
155
+ os.chdir(tmp_path)
156
+ os.makedirs("ara/test_classifier/my_param.data", exist_ok=True)
157
+
158
+ log_file = tmp_path / "ara/test_classifier/my_param.data/test_classifier.prompt_log.md"
159
+
160
+ prompt_handler.append_headings("test_classifier", "my_param", "PROMPT")
161
+ assert "## PROMPT_1" in log_file.read_text()
162
+
163
+ prompt_handler.append_headings("test_classifier", "my_param", "PROMPT")
164
+ assert "## PROMPT_2" in log_file.read_text()
165
+
166
+ prompt_handler.append_headings("test_classifier", "my_param", "RESULT")
167
+ assert "## RESULT_1" in log_file.read_text()
168
+
169
+ class TestArtefactAndTemplateHandling:
170
+ """Tests functions that manage artefact and template files."""
171
+
172
+ @pytest.fixture(autouse=True)
173
+ def setup_fs(self, tmp_path):
174
+ self.root = tmp_path
175
+ os.chdir(self.root)
176
+ self.mock_classifier = "my_artefact"
177
+ self.mock_param = "my_param"
178
+
179
+ self.classifier_patch = patch('ara_cli.prompt_handler.Classifier.get_sub_directory', return_value=self.mock_classifier)
180
+ self.mock_get_sub_dir = self.classifier_patch.start()
181
+
182
+ yield
183
+
184
+ self.classifier_patch.stop()
185
+
186
+ def test_prompt_data_directory_creation(self):
187
+ path = prompt_handler.prompt_data_directory_creation(self.mock_classifier, self.mock_param)
188
+ expected_path = self.root / "ara" / self.mock_classifier / f"{self.mock_param}.data" / "prompt.data"
189
+ assert os.path.exists(expected_path)
190
+ assert Path(path).resolve() == expected_path.resolve()
191
+
192
+ @patch('ara_cli.prompt_handler.generate_markdown_listing')
193
+ def test_generate_config_prompt_givens_file(self, mock_generate_listing, mock_config_manager):
194
+ prompt_data_path = prompt_handler.prompt_data_directory_creation(self.mock_classifier, self.mock_param)
195
+
196
+ prompt_handler.generate_config_prompt_givens_file(prompt_data_path, "config.givens.md")
197
+
198
+ mock_generate_listing.assert_called_once()
199
+ args, _ = mock_generate_listing.call_args
200
+ assert "ara" in args[0]
201
+ assert "./src" in args[0]
202
+ assert "./docs" in args[0]
203
+ assert "./glossary" in args[0]
204
+ assert args[1] == ["*.py"]
205
+ assert args[2] == os.path.join(prompt_data_path, "config.givens.md")
206
+
207
+ @patch('ara_cli.prompt_handler.generate_markdown_listing')
208
+ def test_generate_config_prompt_givens_file_marks_artefact(self, mock_generate_listing, mock_config_manager):
209
+ prompt_data_path = Path(prompt_handler.prompt_data_directory_creation(self.mock_classifier, self.mock_param))
210
+ config_path = prompt_data_path / "config.givens.md"
211
+ artefact_to_mark = "file.py"
212
+
213
+ def create_fake_file(*args, **kwargs):
214
+ content = f"- [] some_other_file.txt\n- [] {artefact_to_mark}\n"
215
+ with open(args[2], 'w') as f:
216
+ f.write(content)
217
+
218
+ mock_generate_listing.side_effect = create_fake_file
219
+
220
+ prompt_handler.generate_config_prompt_givens_file(
221
+ str(prompt_data_path), "config.givens.md", artefact_to_mark=artefact_to_mark
222
+ )
223
+
224
+ content = config_path.read_text()
225
+ assert f"- [x] {artefact_to_mark}" in content
226
+ assert f"- [] some_other_file.txt" in content
227
+
228
+ @patch('ara_cli.prompt_handler.extract_and_load_markdown_files')
229
+ @patch('ara_cli.prompt_handler.move_and_copy_files')
230
+ @patch('ara_cli.prompt_handler.TemplatePathManager.get_template_base_path', return_value="/global/templates")
231
+ def test_load_selected_prompt_templates(self, mock_base_path, mock_move, mock_extract, mock_config_manager):
232
+ prompt_data_path = prompt_handler.prompt_data_directory_creation(self.mock_classifier, self.mock_param)
233
+ config_file = Path(prompt_data_path) / "config.prompt_templates.md"
234
+ config_file.touch()
235
+
236
+ mock_extract.return_value = [
237
+ "custom-prompt-modules/my_custom.rules.md",
238
+ "prompt-modules/global.intention.md",
239
+ "unrecognized/file.md"
240
+ ]
241
+
242
+ prompt_handler.load_selected_prompt_templates(self.mock_classifier, self.mock_param)
243
+
244
+ archive_path = os.path.join(prompt_data_path, "prompt.archive")
245
+
246
+ assert mock_move.call_count == 2
247
+ expected_calls = [
248
+ call(
249
+ os.path.join(mock_config_manager.return_value.local_prompt_templates_dir, "custom-prompt-modules/my_custom.rules.md"),
250
+ prompt_data_path,
251
+ archive_path
252
+ ),
253
+ call(
254
+ os.path.join("/global/templates", "prompt-modules/global.intention.md"),
255
+ prompt_data_path,
256
+ archive_path
257
+ )
258
+ ]
259
+ mock_move.assert_has_calls(expected_calls, any_order=True)
260
+
261
+ def test_extract_and_load_markdown_files(self):
262
+ md_content = """
263
+ # prompt-modules
264
+ ## a-category
265
+ - [x] first.rules.md
266
+ - [] second.rules.md
267
+ # custom-prompt-modules
268
+ - [x] custom.intention.md
269
+ """
270
+ m = mock_open(read_data=md_content)
271
+ with patch('builtins.open', m):
272
+ paths = prompt_handler.extract_and_load_markdown_files("dummy_path")
273
+
274
+ assert len(paths) == 2
275
+ assert 'prompt-modules/a-category/first.rules.md' in paths
276
+ assert 'custom-prompt-modules/custom.intention.md' in paths
277
+
278
+ @patch('ara_cli.prompt_handler.send_prompt')
279
+ @patch('ara_cli.prompt_handler.collect_file_content_by_extension')
280
+ @patch('ara_cli.prompt_handler.append_images_to_message')
281
+ def test_create_and_send_custom_prompt(self, mock_append_images, mock_collect, mock_send):
282
+ prompt_data_path = Path(prompt_handler.prompt_data_directory_creation(self.mock_classifier, self.mock_param))
283
+
284
+ mock_collect.return_value = ("### GIVENS\ncontent", [{"type": "image_url", "image_url": {}}])
285
+
286
+ final_message_list = [{"role": "user", "content": [{"type": "text", "text": "### GIVENS\ncontent"}, {"type": "image_url", "image_url": {}}]}]
287
+ mock_append_images.return_value = final_message_list
288
+
289
+ mock_send.return_value = iter([MagicMock(choices=[MagicMock(delta=MagicMock(content="llm response"))])])
290
+
291
+ prompt_handler.create_and_send_custom_prompt(self.mock_classifier, self.mock_param)
292
+
293
+ mock_collect.assert_called_once()
294
+ mock_append_images.assert_called_once()
295
+ mock_send.assert_called_once_with(final_message_list)
296
+
297
+ artefact_root = self.root / "ara" / self.mock_classifier
298
+ log_file = artefact_root / f"{self.mock_param}.data" / f"{self.mock_classifier}.prompt_log.md"
299
+
300
+ assert log_file.exists()
301
+ log_content = log_file.read_text()
302
+ assert "### GIVENS\ncontent" in log_content
303
+ assert "llm response" in log_content