From d46c7c64a64f5b0b92dc53960502241bd9f9a453 Mon Sep 17 00:00:00 2001 From: Giovanni Porcari Date: Tue, 4 Nov 2025 09:03:49 +0100 Subject: [PATCH 1/5] chore: Add local testing workflow Add script and documentation for running tests locally before push to avoid CI failures. This replicates GitHub Actions checks locally. Changes: - Add scripts/test-local.sh for one-command local testing - Document testing workflow in CLAUDE.md - Include ruff, black, pytest, and mypy checks Note: Windows-specific tests cannot be run locally on macOS --- CLAUDE.md | 32 ++++++++++++++++++++++++++++++++ scripts/test-local.sh | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100755 scripts/test-local.sh diff --git a/CLAUDE.md b/CLAUDE.md index f9faca9..22811bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,38 @@ gtext cast document.gtext --dry-run ## Project-Specific Guidelines +### Testing Before Push + +**MANDATORY**: Always run local tests before pushing to avoid CI failures. + +**Quick command**: +```bash +./scripts/test-local.sh +``` + +**What it tests**: +- ✅ Ruff lint (`ruff check gtext/`) +- ✅ Black formatting (`black --check gtext/`) +- ✅ Pytest with coverage +- ⚠️ Mypy type checking (non-blocking) + +**What it CANNOT test**: +- ❌ Windows-specific issues (we're on macOS) + +**Manual run** (if script fails): +```bash +# Individual commands +ruff check gtext/ +black --check gtext/ +pytest --cov=gtext --cov-report=term-missing +mypy gtext/ # Optional, errors won't fail +``` + +**Auto-fix formatting** (if black check fails): +```bash +black gtext/ +``` + ### When Adding Extensions 1. Create new file in `gtext/extensions/` diff --git a/scripts/test-local.sh b/scripts/test-local.sh new file mode 100755 index 0000000..2fdaf38 --- /dev/null +++ b/scripts/test-local.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Local testing script - run before pushing to avoid CI failures +# This replicates the GitHub Actions CI checks locally + +set -e # Exit on first error + +echo "🧪 Running local tests..." +echo "" + +# Change to project root +cd "$(dirname "$0")/.." + +echo "📦 Installing dependencies..." +pip install -e ".[dev]" > /dev/null 2>&1 + +echo "" +echo "🔍 1/4 Running ruff lint..." +ruff check gtext/ + +echo "" +echo "🎨 2/4 Checking code formatting with black..." +black --check gtext/ + +echo "" +echo "🧪 3/4 Running pytest with coverage..." +pytest --cov=gtext --cov-report=term-missing + +echo "" +echo "📝 4/4 Running mypy (optional, errors won't fail)..." +mypy gtext/ || echo "⚠️ Mypy found issues (non-blocking)" + +echo "" +echo "✅ All local tests passed! Safe to push." +echo "" +echo "⚠️ Note: Windows-specific tests cannot be run locally on macOS" From e4abcd4e823eb64b44d4e7e6b65fa4725679b476 Mon Sep 17 00:00:00 2001 From: Giovanni Porcari Date: Tue, 4 Nov 2025 09:39:40 +0100 Subject: [PATCH 2/5] refactor: Remove implicit static: fallback (Phase 1.1) Remove backward compatibility for implicit static: protocol. The static: protocol is now mandatory for all static file includes. Changes: - include.py: Return 'unknown' protocol instead of 'static' for lines without explicit protocol - Updated 4 tests to use explicit static: syntax - Renamed test_modifier_without_protocol_uses_static to test_modifier_without_protocol_errors Breaking change: Files using `{path}` must now use `static: {path}` Related to Issue #6 --- gtext/extensions/include.py | 4 ++-- tests/test_include_errors.py | 9 +++++---- tests/test_processor.py | 6 +++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/gtext/extensions/include.py b/gtext/extensions/include.py index b4a56d2..e474e55 100644 --- a/gtext/extensions/include.py +++ b/gtext/extensions/include.py @@ -174,8 +174,8 @@ def _parse_line(self, line: str) -> tuple: if protocol in self.PROTOCOLS: return (modifiers, protocol, actual_content) - # No explicit protocol = static (backward compatibility) - return (modifiers, "static", content.strip()) + # No explicit protocol = error (static: is now mandatory) + return (modifiers, "unknown", content.strip()) def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: """Resolve a single include line using protocol handlers with modifiers support. diff --git a/tests/test_include_errors.py b/tests/test_include_errors.py index 57b180e..ceed8aa 100644 --- a/tests/test_include_errors.py +++ b/tests/test_include_errors.py @@ -359,8 +359,8 @@ def test_expand_modifier_max_depth_exceeded(tmp_path): assert "ERROR: Max include depth" in result or "```include" in result -def test_modifier_without_protocol_uses_static(tmp_path): - """Test modifier without explicit protocol defaults to static.""" +def test_modifier_without_protocol_errors(tmp_path): + """Test modifier without explicit protocol generates error (static: is now mandatory).""" content_file = tmp_path / "test.txt" content_file.write_text("Test content here.") @@ -372,5 +372,6 @@ def test_modifier_without_protocol_uses_static(tmp_path): result = processor.process_string(template, context={"cwd": tmp_path}) - # Should treat as :expand:static: - assert "Test content here" in result + # Should generate error (no implicit fallback to static:) + assert "ERROR" in result + assert "Unknown protocol" in result diff --git a/tests/test_processor.py b/tests/test_processor.py index bd9bca1..96460f3 100644 --- a/tests/test_processor.py +++ b/tests/test_processor.py @@ -38,7 +38,7 @@ def test_process_string_with_static_include(): # Create source file source = tmpdir / "source.md.gtext" - source.write_text(f"# Test\n\n```include\n{included}\n```") + source.write_text(f"# Test\n\n```include\nstatic: {included}\n```") # Process result = processor.process_file(source) @@ -281,7 +281,7 @@ def test_expand_modifier_parsing(): (":expand:static: file.md", (['expand'], 'static', 'file.md')), (":expand:cli: echo hello", (['expand'], 'cli', 'echo hello')), ("cli: date", ([], 'cli', 'date')), - ("file.md", ([], 'static', 'file.md')), # Backward compat + ("file.md", ([], 'unknown', 'file.md')), # No implicit protocol (static: now mandatory) (":expand:glob: *.txt", (['expand'], 'glob', '*.txt')), ] @@ -316,7 +316,7 @@ def test_expand_modifier_multiple_levels(): level2 = tmpdir / "level2.gtext" level2.write_text(f"""Level 2: ```include -{level3} +static: {level3} ```""") # Create level 1 (includes level 2 with expand) From 235d795ad5e1a5ccd580ac09353345af625e6d68 Mon Sep 17 00:00:00 2001 From: Giovanni Porcari Date: Tue, 4 Nov 2025 10:39:26 +0100 Subject: [PATCH 3/5] refactor: Rename terminology from modifier/protocol to command (Phase 1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify terminology to use "command" everywhere, eliminating the distinction between "modifiers" and "protocols" from user perspective. Internally, maintain two categories: - SOURCE_COMMANDS: fetch data (static, cli, glob) - TRANSFORM_COMMANDS: transform content (expand, tldr, translate) Changes: - Rename PROTOCOLS → SOURCE_COMMANDS - Rename MODIFIERS → TRANSFORM_COMMANDS - Update all docstrings and comments to use "command" terminology - Change error message from "Unknown protocol" to "Unknown command" - Update test expectations for new error message Related to Issue #6, Decision #2 --- gtext/extensions/include.py | 77 +++++++++++++++++++----------------- tests/test_include_errors.py | 2 +- 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/gtext/extensions/include.py b/gtext/extensions/include.py index e474e55..d921c9f 100644 --- a/gtext/extensions/include.py +++ b/gtext/extensions/include.py @@ -13,17 +13,21 @@ class IncludeExtension(BaseExtension): """Extension that processes ```include blocks. - Supports multiple protocols: - - static: Static files (default if no protocol specified) + Supports two types of commands: + + Source commands (fetch data): + - static: Static files (must be explicit) - cli: Execute shell commands - glob: Multiple files via glob patterns - Supports modifiers (prefix with :modifier:): - - expand: Recursively process the included content + Transform commands (process content): + - expand: Recursively process the included content (will be renamed to 'render') + - tldr: AI-powered summarization + - translate: AI-powered translation Syntax: - protocol: content # Basic - :expand:protocol: content # With expand modifier + source_command: content # Basic + :transform_command:source_command: content # With transform Examples: ```include @@ -32,10 +36,9 @@ class IncludeExtension(BaseExtension): cli: python get_stats.py # Execute command :expand:cli: python generate_doc.py # Execute and expand output glob: sections/*.md # Include multiple files - footer.md # Implicit static: ``` - Backward compatibility: Lines without protocol are treated as static: paths. + Note: static: is now mandatory (no implicit fallback) """ name = "include" @@ -43,16 +46,16 @@ class IncludeExtension(BaseExtension): # Regex to match ```include blocks INCLUDE_PATTERN = re.compile(r"```include\s*\n(.*?)```", re.DOTALL | re.MULTILINE) - # Protocol handlers - PROTOCOLS = { + # Source commands (fetch data, require handlers) + SOURCE_COMMANDS = { "static": "_handle_static", "cli": "_handle_cli", "glob": "_handle_glob", } - # Supported modifiers - MODIFIERS = { - "expand", # Recursively expand included content + # Transform commands (transform content, applied as pipeline) + TRANSFORM_COMMANDS = { + "expand", # Recursively expand included content (will be renamed to 'render') "tldr", # AI-powered summarization "translate", # AI-powered translation } @@ -104,29 +107,29 @@ def _resolve_include_block(self, block: str, context: Dict) -> str: return "\n".join(results) def _parse_line(self, line: str) -> tuple: - """Parse line into (modifiers, protocol, content). + """Parse line into (transforms, source_command, content). - Syntax: :modifier1[params]:modifier2:protocol: content + Syntax: :transform1[params]:transform2:source_command: content Args: line: Include directive line Returns: - Tuple of (list of modifier_specs, protocol name, content) - where modifier_specs can be: - - string: modifier name without parameters - - tuple: (modifier_name, params_dict) with parameters + Tuple of (list of transform_specs, source_command name, content) + where transform_specs can be: + - string: transform name without parameters + - tuple: (transform_name, params_dict) with parameters Examples: "static: file.md" → ([], 'static', 'file.md') ":expand:cli: date" → (['expand'], 'cli', 'date') ":translate[it]:static: file.md" → ([('translate', {'lang': 'it'}], 'static', 'file.md') - "file.md" → ([], 'static', 'file.md') + "file.md" → ([], 'unknown', 'file.md') # Error: static: now mandatory """ - modifiers = [] + modifiers = [] # Will contain transform commands content = line - # Parse modifiers (lines starting with :) + # Parse transforms (lines starting with :) while content.startswith(":"): content = content[1:] # Remove leading : @@ -149,36 +152,36 @@ def _parse_line(self, line: str) -> tuple: # For now, simple format: single parameter is the target language mod_params = {"lang": params_str.strip()} - if mod_name in self.MODIFIERS: + if mod_name in self.TRANSFORM_COMMANDS: if mod_params: modifiers.append((mod_name, mod_params)) else: modifiers.append(mod_name) content = parts[1] - elif mod_name in self.PROTOCOLS: - # This is a protocol, not a modifier - # Parse as protocol:content + elif mod_name in self.SOURCE_COMMANDS: + # This is a source command, not a transform + # Parse as source_command:content protocol = mod_name actual_content = parts[1].strip() return (modifiers, protocol, actual_content) else: - # Unknown modifier, stop parsing + # Unknown command, stop parsing break - # No more modifiers, parse protocol + # No more transforms, parse source command if ":" in content: parts = content.split(":", 1) protocol = parts[0].strip() actual_content = parts[1].strip() - if protocol in self.PROTOCOLS: + if protocol in self.SOURCE_COMMANDS: return (modifiers, protocol, actual_content) # No explicit protocol = error (static: is now mandatory) return (modifiers, "unknown", content.strip()) def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: - """Resolve a single include line using protocol handlers with modifiers support. + """Resolve a single include line using source command handlers with transform support. Args: line: The include directive line @@ -188,14 +191,14 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: Returns: Resolved content from the line """ - # Parse modifiers, protocol, content + # Parse transforms, source command, content modifiers, protocol, content = self._parse_line(line) # Get handler - if protocol not in self.PROTOCOLS: - return f"" + if protocol not in self.SOURCE_COMMANDS: + return f"" - handler_name = self.PROTOCOLS[protocol] + handler_name = self.SOURCE_COMMANDS[protocol] handler = getattr(self, handler_name) # Security check: verify command is allowed @@ -207,9 +210,9 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: # Execute handler result = handler(content, base_dir, context) - # Apply modifiers + # Apply transform commands for modifier in modifiers: - # Handle both string modifiers and (name, params) tuples + # Handle both string transforms and (name, params) tuples if isinstance(modifier, tuple): mod_name, mod_params = modifier else: @@ -224,7 +227,7 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: result = self._tldr_content(result, context) elif mod_name == "translate": # AI-powered translation - # Merge modifier params into context + # Merge transform params into context translate_context = context.copy() if "lang" in mod_params: translate_context["translate_target"] = mod_params["lang"] diff --git a/tests/test_include_errors.py b/tests/test_include_errors.py index ceed8aa..961797c 100644 --- a/tests/test_include_errors.py +++ b/tests/test_include_errors.py @@ -374,4 +374,4 @@ def test_modifier_without_protocol_errors(tmp_path): # Should generate error (no implicit fallback to static:) assert "ERROR" in result - assert "Unknown protocol" in result + assert "Unknown command" in result From fbbf7e82d169f756cd9d67c43f7026c8c56f1726 Mon Sep 17 00:00:00 2001 From: Giovanni Porcari Date: Tue, 4 Nov 2025 11:22:05 +0100 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20Rename=20expand=20=E2=86=92=20r?= =?UTF-8?q?ender=20transform=20command=20(Phase=201.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the 'expand' transform command to 'render' for clarity. The render command recursively processes included content. Changes: - TRANSFORM_COMMANDS: "expand" → "render" - Method: _expand_content → _render_content - All docstrings and examples updated - All test files updated (:expand: → :render:) - Test expectations updated ('expand' → 'render' in tuples) Breaking change: `:expand:` must now be written as `:render:` Related to Issue #6, Decision #7 --- gtext/extensions/include.py | 26 +++++++++++++------------- tests/test_include_errors.py | 12 ++++++------ tests/test_processor.py | 28 ++++++++++++++-------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/gtext/extensions/include.py b/gtext/extensions/include.py index d921c9f..d143656 100644 --- a/gtext/extensions/include.py +++ b/gtext/extensions/include.py @@ -21,7 +21,7 @@ class IncludeExtension(BaseExtension): - glob: Multiple files via glob patterns Transform commands (process content): - - expand: Recursively process the included content (will be renamed to 'render') + - render: Recursively process the included content - tldr: AI-powered summarization - translate: AI-powered translation @@ -32,9 +32,9 @@ class IncludeExtension(BaseExtension): Examples: ```include static: header.md # Include file as-is - :expand:static: template.md.gtext # Include and expand recursively + :render:static: template.md.gtext # Include and render recursively cli: python get_stats.py # Execute command - :expand:cli: python generate_doc.py # Execute and expand output + :render:cli: python generate_doc.py # Execute and render output glob: sections/*.md # Include multiple files ``` @@ -55,7 +55,7 @@ class IncludeExtension(BaseExtension): # Transform commands (transform content, applied as pipeline) TRANSFORM_COMMANDS = { - "expand", # Recursively expand included content (will be renamed to 'render') + "render", # Recursively process included content (renamed from 'expand') "tldr", # AI-powered summarization "translate", # AI-powered translation } @@ -122,7 +122,7 @@ def _parse_line(self, line: str) -> tuple: Examples: "static: file.md" → ([], 'static', 'file.md') - ":expand:cli: date" → (['expand'], 'cli', 'date') + ":render:cli: date" → (['render'], 'cli', 'date') ":translate[it]:static: file.md" → ([('translate', {'lang': 'it'}], 'static', 'file.md') "file.md" → ([], 'unknown', 'file.md') # Error: static: now mandatory """ @@ -219,9 +219,9 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: mod_name = modifier mod_params = {} - if mod_name == "expand": + if mod_name == "render": # Recursively process the result - result = self._expand_content(result, base_dir, context) + result = self._render_content(result, base_dir, context) elif mod_name == "tldr": # AI-powered summarization result = self._tldr_content(result, context) @@ -235,16 +235,16 @@ def _resolve_line(self, line: str, base_dir: Path, context: Dict) -> str: return result - def _expand_content(self, content: str, base_dir: Path, context: Dict) -> str: - """Recursively expand content that may contain ```include blocks. + def _render_content(self, content: str, base_dir: Path, context: Dict) -> str: + """Recursively render content that may contain ```include blocks. Args: - content: Content to expand + content: Content to render base_dir: Base directory for resolution context: Context dict Returns: - Expanded content with all ```include blocks resolved + Rendered content with all ```include blocks resolved Note: Tracks recursion depth to prevent infinite loops. @@ -269,12 +269,12 @@ def replace_include(match): include_block = match.group(1).strip() return self._resolve_include_block(include_block, context) - expanded = self.INCLUDE_PATTERN.sub(replace_include, content) + rendered = self.INCLUDE_PATTERN.sub(replace_include, content) # Restore depth context["include_depth"] = depth - return expanded + return rendered def _tldr_content(self, content: str, context: Dict) -> str: """Generate AI-powered summary of content. diff --git a/tests/test_include_errors.py b/tests/test_include_errors.py index 961797c..94b3d33 100644 --- a/tests/test_include_errors.py +++ b/tests/test_include_errors.py @@ -307,7 +307,7 @@ def test_multiple_modifiers_chained(tmp_path): def test_expand_modifier_with_nested_includes(tmp_path): - """Test :expand: modifier processes nested includes.""" + """Test :render: modifier processes nested includes.""" # Create nested structure inner_file = tmp_path / "inner.txt" inner_file.write_text("Inner content") @@ -321,7 +321,7 @@ def test_expand_modifier_with_nested_includes(tmp_path): processor = TextProcessor() template = f"""```include -:expand:static: {outer_file} +:render:static: {outer_file} ```""" result = processor.process_string(template, context={"cwd": tmp_path}) @@ -332,7 +332,7 @@ def test_expand_modifier_with_nested_includes(tmp_path): def test_expand_modifier_max_depth_exceeded(tmp_path): - """Test :expand: respects max_include_depth.""" + """Test :render: respects max_include_depth.""" # Create files with nested includes file3 = tmp_path / "file3.md" file3.write_text("Deep content") @@ -345,7 +345,7 @@ def test_expand_modifier_max_depth_exceeded(tmp_path): processor = TextProcessor() template = f"""```include -:expand:static: {file1} +:render:static: {file1} ```""" context = { @@ -365,9 +365,9 @@ def test_modifier_without_protocol_errors(tmp_path): content_file.write_text("Test content here.") processor = TextProcessor() - # No protocol specified after :expand: + # No protocol specified after :render: template = f"""```include -:expand:{content_file} +:render:{content_file} ```""" result = processor.process_string(template, context={"cwd": tmp_path}) diff --git a/tests/test_processor.py b/tests/test_processor.py index 96460f3..9e0edb3 100644 --- a/tests/test_processor.py +++ b/tests/test_processor.py @@ -146,7 +146,7 @@ def test_include_mixed_types(): @pytest.mark.skipif(sys.platform == "win32", reason="Unix commands not available on Windows") def test_expand_modifier_static_file(): - """Test :expand: modifier with static file includes.""" + """Test :render: modifier with static file includes.""" processor = TextProcessor() with tempfile.TemporaryDirectory() as tmpdir: @@ -180,7 +180,7 @@ def test_expand_modifier_static_file(): ## With expand: ```include -:expand:static: {template} +:render:static: {template} ``` """) @@ -201,7 +201,7 @@ def test_expand_modifier_static_file(): @pytest.mark.skipif(sys.platform == "win32", reason="Unix commands not available on Windows") def test_expand_modifier_cli_command(): - """Test :expand: modifier with CLI command.""" + """Test :render: modifier with CLI command.""" processor = TextProcessor() with tempfile.TemporaryDirectory() as tmpdir: @@ -223,7 +223,7 @@ def test_expand_modifier_cli_command(): source.write_text(f"""# Test ```include -:expand:cli: {script} +:render:cli: {script} ``` """) @@ -247,7 +247,7 @@ def test_expand_modifier_depth_limit(): recursive.write_text(f"""# Recursive ```include -:expand:static: {recursive} +:render:static: {recursive} ``` """) @@ -255,7 +255,7 @@ def test_expand_modifier_depth_limit(): source.write_text(f"""# Test ```include -:expand:static: {recursive} +:render:static: {recursive} ``` """) @@ -278,11 +278,11 @@ def test_expand_modifier_parsing(): # Test various parsing scenarios test_cases = [ ("static: file.md", ([], 'static', 'file.md')), - (":expand:static: file.md", (['expand'], 'static', 'file.md')), - (":expand:cli: echo hello", (['expand'], 'cli', 'echo hello')), + (":render:static: file.md", (['render'], 'static', 'file.md')), + (":render:cli: echo hello", (['render'], 'cli', 'echo hello')), ("cli: date", ([], 'cli', 'date')), ("file.md", ([], 'unknown', 'file.md')), # No implicit protocol (static: now mandatory) - (":expand:glob: *.txt", (['expand'], 'glob', '*.txt')), + (":render:glob: *.txt", (['render'], 'glob', '*.txt')), ] for line, expected in test_cases: @@ -291,13 +291,13 @@ def test_expand_modifier_parsing(): def test_expand_content_no_includes(): - """Test that expand_content returns unchanged content if no includes present.""" + """Test that render_content returns unchanged content if no includes present.""" from gtext.extensions.include import IncludeExtension ext = IncludeExtension() content = "# Simple content\n\nNo includes here." - result = ext._expand_content(content, Path.cwd(), {}) + result = ext._render_content(content, Path.cwd(), {}) assert result == content @@ -323,7 +323,7 @@ def test_expand_modifier_multiple_levels(): level1 = tmpdir / "level1.gtext" level1.write_text(f"""Level 1: ```include -:expand:static: {level2} +:render:static: {level2} ```""") # Create source (includes level 1 with expand) @@ -331,7 +331,7 @@ def test_expand_modifier_multiple_levels(): source.write_text(f"""# Multi-level Test ```include -:expand:static: {level1} +:render:static: {level1} ``` """) @@ -446,7 +446,7 @@ def test_expand_with_glob(tmp_path): # Use expand to process the template file main_template = f"""```include -:expand:static: {template_file} +:render:static: {template_file} ```""" result = processor.process_string(main_template, context={"cwd": tmp_path}) From 25723e250f5efe59578dbf58e0b35b150f4b609b Mon Sep 17 00:00:00 2001 From: Giovanni Porcari Date: Wed, 5 Nov 2025 05:33:14 +0100 Subject: [PATCH 5/5] docs: Add We-Birds family badge to README Add prominent family membership badge after main badges section to establish gtext as part of the We-Birds toolkit brand. Consistent with smartswitch branding. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 7f2410c..c321b22 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ --- +> 🐦 **Part of the [We-Birds](https://github.com/genropy/we-birds) family** +> *Developer tools that fly together* + ## ✨ What is gtext? gtext is a **universal text processor** with a pluggable extension system. Transform any text file through customizable plugins: