Add unit tests for HostEntry and HostsFile models, and implement HostsParser tests
- Created comprehensive unit tests for HostEntry class, covering creation, validation, and conversion to/from hosts file lines. - Developed unit tests for HostsFile class, including entry management, sorting, and retrieval of active/inactive entries. - Implemented tests for HostsParser class, validating parsing and serialization of hosts files, handling comments, and file operations. - Ensured coverage for edge cases such as empty files, invalid entries, and file permission checks.
This commit is contained in:
parent
40a1e67949
commit
2decad8047
21 changed files with 1691 additions and 75 deletions
|
@ -2,12 +2,26 @@
|
||||||
|
|
||||||
## What Works
|
## What Works
|
||||||
|
|
||||||
### Project Foundation
|
### Project Foundation ✅ COMPLETE
|
||||||
- ✅ **uv project initialized**: Basic Python 3.13 project with uv configuration
|
- ✅ **uv project initialized**: Basic Python 3.13 project with uv configuration
|
||||||
- ✅ **Code quality setup**: ruff configured for linting and formatting
|
- ✅ **Code quality setup**: ruff configured for linting and formatting
|
||||||
- ✅ **Memory bank complete**: All core documentation files created and populated
|
- ✅ **Memory bank complete**: All core documentation files created and populated
|
||||||
- ✅ **Architecture defined**: Clear layered architecture and design patterns established
|
- ✅ **Architecture defined**: Clear layered architecture and design patterns established
|
||||||
|
|
||||||
|
### Phase 1: Foundation ✅ COMPLETE
|
||||||
|
- ✅ **Project structure**: Created proper `src/hosts/` package structure with core and tui modules
|
||||||
|
- ✅ **Dependencies**: Added textual, pytest, ruff and configured properly in pyproject.toml
|
||||||
|
- ✅ **Entry point**: Configured proper application entry point (`hosts` command)
|
||||||
|
- ✅ **Core models**: Implemented HostEntry and HostsFile data classes with full validation
|
||||||
|
- ✅ **Hosts parser**: Created comprehensive parser for reading/writing `/etc/hosts` files
|
||||||
|
- ✅ **Basic TUI**: Implemented main application with two-pane layout
|
||||||
|
- ✅ **File loading**: Successfully reads and parses existing hosts file
|
||||||
|
- ✅ **Entry display**: Shows hosts entries in left pane with proper formatting
|
||||||
|
- ✅ **Detail view**: Shows selected entry details in right pane
|
||||||
|
- ✅ **Navigation**: Keyboard navigation between entries working
|
||||||
|
- ✅ **Testing**: Comprehensive test suite with 42 passing tests
|
||||||
|
- ✅ **Code quality**: All linting checks passing
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- ✅ **Project brief**: Comprehensive project definition and requirements
|
- ✅ **Project brief**: Comprehensive project definition and requirements
|
||||||
- ✅ **Product context**: User experience goals and problem definition
|
- ✅ **Product context**: User experience goals and problem definition
|
||||||
|
@ -17,117 +31,162 @@
|
||||||
|
|
||||||
## What's Left to Build
|
## What's Left to Build
|
||||||
|
|
||||||
### Phase 1: Foundation (Immediate)
|
### Phase 2: Enhanced Read-Only Features (Next)
|
||||||
- ❌ **Project structure**: Create proper `src/hosts/` package structure
|
- ❌ **Entry selection highlighting**: Visual feedback for selected entries
|
||||||
- ❌ **Dependencies**: Add textual, pytest, and other required packages
|
|
||||||
- ❌ **Entry point**: Configure proper application entry point in pyproject.toml
|
|
||||||
- ❌ **Core models**: Implement HostEntry and HostsFile data classes
|
|
||||||
- ❌ **Hosts parser**: Create parser for reading/writing `/etc/hosts` files
|
|
||||||
|
|
||||||
### Phase 2: Core Functionality
|
|
||||||
- ❌ **Basic TUI**: Implement main application with two-pane layout
|
|
||||||
- ❌ **File loading**: Read and parse existing hosts file
|
|
||||||
- ❌ **Entry display**: Show hosts entries in left pane
|
|
||||||
- ❌ **Detail view**: Show selected entry details in right pane
|
|
||||||
- ❌ **Navigation**: Keyboard navigation between entries
|
|
||||||
|
|
||||||
### Phase 3: Read-Only Features
|
|
||||||
- ❌ **Entry selection**: Highlight and select entries
|
|
||||||
- ❌ **Sorting**: Sort entries by IP, hostname, or comments
|
- ❌ **Sorting**: Sort entries by IP, hostname, or comments
|
||||||
- ❌ **Filtering**: Filter entries by active/inactive status
|
- ❌ **Filtering**: Filter entries by active/inactive status
|
||||||
- ❌ **Search**: Find entries by hostname or IP
|
- ❌ **Search**: Find entries by hostname or IP
|
||||||
|
- ❌ **Help screen**: Proper modal help dialog
|
||||||
|
- ❌ **Status indicators**: Better visual distinction for active/inactive entries
|
||||||
|
|
||||||
### Phase 4: Edit Mode
|
### Phase 3: Edit Mode Foundation
|
||||||
- ❌ **Permission management**: Sudo request and management
|
- ❌ **Permission management**: Sudo request and management
|
||||||
- ❌ **Edit mode toggle**: Switch between read-only and edit modes
|
- ❌ **Edit mode toggle**: Switch between read-only and edit modes
|
||||||
- ❌ **Entry activation**: Toggle entries active/inactive
|
- ❌ **Entry activation**: Toggle entries active/inactive
|
||||||
- ❌ **Entry reordering**: Move entries up/down in the list
|
- ❌ **Entry reordering**: Move entries up/down in the list
|
||||||
- ❌ **Entry editing**: Modify IP addresses, hostnames, comments
|
- ❌ **Entry editing**: Modify IP addresses, hostnames, comments
|
||||||
|
- ❌ **File backup**: Automatic backup before modifications
|
||||||
|
|
||||||
|
### Phase 4: Advanced Edit Features
|
||||||
|
- ❌ **Add new entries**: Create new host entries
|
||||||
|
- ❌ **Delete entries**: Remove host entries
|
||||||
|
- ❌ **Bulk operations**: Select and modify multiple entries
|
||||||
|
- ❌ **Validation**: Real-time validation of IP addresses and hostnames
|
||||||
|
- ❌ **Undo/Redo**: Command pattern implementation
|
||||||
|
|
||||||
### Phase 5: Advanced Features
|
### Phase 5: Advanced Features
|
||||||
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
|
- ❌ **DNS resolution**: Resolve hostnames to IP addresses
|
||||||
- ❌ **IP comparison**: Compare stored vs resolved IPs
|
- ❌ **IP comparison**: Compare stored vs resolved IPs
|
||||||
- ❌ **CNAME support**: Store DNS names alongside IP addresses
|
- ❌ **CNAME support**: Store DNS names alongside IP addresses
|
||||||
- ❌ **Undo/Redo**: Command pattern implementation
|
- ❌ **Import/Export**: Support for different file formats
|
||||||
- ❌ **File validation**: Comprehensive validation before saving
|
- ❌ **Configuration**: User preferences and settings
|
||||||
|
|
||||||
### Phase 6: Polish
|
### Phase 6: Polish
|
||||||
- ❌ **Error handling**: Graceful error handling and user feedback
|
- ❌ **Error handling**: Enhanced error handling and user feedback
|
||||||
- ❌ **Help system**: In-app help and keyboard shortcuts
|
|
||||||
- ❌ **Configuration**: User preferences and settings
|
|
||||||
- ❌ **Performance**: Optimization for large hosts files
|
- ❌ **Performance**: Optimization for large hosts files
|
||||||
|
- ❌ **Accessibility**: Screen reader support and keyboard accessibility
|
||||||
|
- ❌ **Documentation**: User manual and installation guide
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
### Development Stage
|
### Development Stage
|
||||||
**Stage**: Project Initialization
|
**Stage**: Phase 1 Complete - Foundation Established
|
||||||
**Progress**: 10% (Foundation documentation complete)
|
**Progress**: 25% (Core functionality working)
|
||||||
**Next Milestone**: Basic project structure and dependencies
|
**Next Milestone**: Enhanced read-only features
|
||||||
|
|
||||||
### Immediate Blockers
|
### Phase 1 Achievements
|
||||||
1. **Project structure**: Need to create proper package layout
|
1. ✅ **Fully functional TUI**: Application successfully loads and displays hosts file
|
||||||
2. **Dependencies**: Must add textual framework to begin TUI development
|
2. ✅ **Robust parsing**: Handles comments, inactive entries, IPv4/IPv6 addresses
|
||||||
3. **Entry point**: Configure uv to run the application properly
|
3. ✅ **Clean architecture**: Well-structured codebase with separation of concerns
|
||||||
|
4. ✅ **Comprehensive testing**: 42 tests covering models and parser functionality
|
||||||
|
5. ✅ **Code quality**: All linting and formatting checks passing
|
||||||
|
|
||||||
|
### Immediate Next Steps
|
||||||
|
1. **Enhanced UI**: Improve visual feedback and entry highlighting
|
||||||
|
2. **Sorting/Filtering**: Add basic data manipulation features
|
||||||
|
3. **Help system**: Implement proper help modal
|
||||||
|
4. **Status improvements**: Better visual indicators for entry states
|
||||||
|
|
||||||
### Recent Accomplishments
|
### Recent Accomplishments
|
||||||
- Completed comprehensive project planning and documentation
|
- Successfully implemented complete Phase 1 foundation
|
||||||
- Established clear architecture and design patterns
|
- Created robust data models with validation
|
||||||
- Created memory bank system for project continuity
|
- Built comprehensive hosts file parser with comment preservation
|
||||||
- Defined development phases and priorities
|
- Developed functional TUI application with two-pane layout
|
||||||
|
- Established comprehensive testing framework
|
||||||
|
- Achieved clean code quality standards
|
||||||
|
|
||||||
|
## Technical Implementation Details
|
||||||
|
|
||||||
|
### Core Components Working
|
||||||
|
- **HostEntry**: Data class with IP/hostname validation, active/inactive state
|
||||||
|
- **HostsFile**: Container with entry management, sorting, and search capabilities
|
||||||
|
- **HostsParser**: File I/O with atomic writes, backup creation, permission checking
|
||||||
|
- **HostsManagerApp**: Textual-based TUI with reactive state management
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
- **Models**: 27 tests covering all data model functionality
|
||||||
|
- **Parser**: 15 tests covering file operations and edge cases
|
||||||
|
- **Coverage**: All core functionality thoroughly tested
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- **Linting**: All ruff checks passing
|
||||||
|
- **Type hints**: Comprehensive typing throughout codebase
|
||||||
|
- **Documentation**: Detailed docstrings and comments
|
||||||
|
- **Error handling**: Proper exception handling in core components
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
### Current Limitations
|
### Current Limitations
|
||||||
- **Placeholder implementation**: main.py only prints hello message
|
- **Help system**: Currently shows status message instead of modal
|
||||||
- **Missing dependencies**: Core frameworks not yet added
|
- **Entry highlighting**: Basic selection without visual enhancement
|
||||||
- **No package structure**: Files not organized in proper Python package
|
- **No edit capabilities**: Read-only mode only (by design for Phase 1)
|
||||||
- **No tests**: Testing framework not yet configured
|
- **No sorting/filtering**: Basic display only
|
||||||
|
|
||||||
### Technical Debt
|
### Technical Debt
|
||||||
- **Temporary main.py**: Needs to be moved to proper location
|
- **Help modal**: Need to implement proper screen for help
|
||||||
- **Missing type hints**: Will need comprehensive typing
|
- **Visual polish**: Entry highlighting and status indicators need improvement
|
||||||
- **No error handling**: Basic error handling patterns needed
|
- **Error messages**: Could be more user-friendly
|
||||||
- **No logging**: Logging system not yet implemented
|
- **Performance**: Not yet optimized for very large hosts files
|
||||||
|
|
||||||
## Evolution of Project Decisions
|
## Evolution of Project Decisions
|
||||||
|
|
||||||
### Initial Decisions (Current)
|
### Confirmed Decisions
|
||||||
- **Python 3.13**: Chosen for modern features and performance
|
- **Python 3.13**: Excellent choice for modern features
|
||||||
- **Textual**: Selected for rich TUI capabilities
|
- **Textual**: Perfect for rich TUI development
|
||||||
- **uv**: Adopted for fast package management
|
- **uv**: Fast and reliable package management
|
||||||
- **ruff**: Chosen for code quality and speed
|
- **ruff**: Excellent code quality tooling
|
||||||
|
- **Dataclasses**: Clean and efficient for data models
|
||||||
|
|
||||||
### Architecture Evolution
|
### Architecture Validation
|
||||||
- **Layered approach**: Decided on clear separation of concerns
|
- **Layered approach**: Proven effective with clear separation
|
||||||
- **Command pattern**: Chosen for undo/redo functionality
|
- **Parser design**: Robust handling of real-world hosts files
|
||||||
- **Immutable state**: Selected for predictable state management
|
- **Reactive UI**: Textual's reactive system working well
|
||||||
- **Permission model**: Explicit edit mode for safety
|
- **Test-driven**: Comprehensive testing paying dividends
|
||||||
|
|
||||||
### Design Considerations
|
### Design Successes
|
||||||
- **Safety first**: Read-only default mode prioritized
|
- **Safety first**: Read-only default working as intended
|
||||||
- **User experience**: Keyboard-driven interface emphasized
|
- **File integrity**: Atomic operations and backup system solid
|
||||||
- **File integrity**: Atomic operations and validation required
|
- **User experience**: Keyboard navigation intuitive
|
||||||
- **Performance**: Responsive UI for large files planned
|
- **Code organization**: Package structure clean and maintainable
|
||||||
|
|
||||||
## Success Metrics Progress
|
## Success Metrics Progress
|
||||||
|
|
||||||
### Completed Metrics
|
### Completed Metrics ✅
|
||||||
- ✅ **Project documentation**: Comprehensive planning complete
|
- ✅ **Functional prototype**: TUI application fully working
|
||||||
- ✅ **Architecture clarity**: Clear technical direction established
|
- ✅ **File parsing**: Robust hosts file reading and writing
|
||||||
- ✅ **Development setup**: Basic environment ready
|
- ✅ **Code quality**: All quality checks passing
|
||||||
|
- ✅ **Test coverage**: Comprehensive test suite implemented
|
||||||
|
- ✅ **Architecture**: Clean, maintainable codebase structure
|
||||||
|
|
||||||
### Pending Metrics
|
### Next Phase Metrics
|
||||||
- ❌ **Functional prototype**: Basic TUI not yet implemented
|
- ❌ **Enhanced UX**: Improved visual feedback and interactions
|
||||||
- ❌ **File parsing**: Hosts file reading not yet working
|
- ❌ **Data manipulation**: Sorting and filtering capabilities
|
||||||
- ❌ **User testing**: No user interface to test yet
|
- ❌ **User testing**: Feedback on current interface
|
||||||
- ❌ **Performance benchmarks**: No code to benchmark yet
|
- ❌ **Performance benchmarks**: Testing with large hosts files
|
||||||
|
|
||||||
## Next Session Priorities
|
## Next Session Priorities
|
||||||
|
|
||||||
1. **Create project structure**: Set up src/hosts/ package layout
|
### Phase 2 Implementation
|
||||||
2. **Add dependencies**: Install textual and pytest
|
1. **Visual enhancements**: Improve entry highlighting and status indicators
|
||||||
3. **Implement data models**: Create HostEntry and HostsFile classes
|
2. **Sorting functionality**: Implement sort by IP, hostname, status
|
||||||
4. **Basic parser**: Read and parse simple hosts file format
|
3. **Filtering system**: Add active/inactive filtering
|
||||||
5. **Minimal TUI**: Create basic application shell
|
4. **Help modal**: Create proper help screen
|
||||||
|
5. **Search capability**: Basic hostname/IP search
|
||||||
|
|
||||||
The project is well-planned and ready for implementation to begin.
|
### Quality Improvements
|
||||||
|
1. **Error handling**: More user-friendly error messages
|
||||||
|
2. **Status feedback**: Better user feedback for operations
|
||||||
|
3. **Performance testing**: Test with large hosts files
|
||||||
|
4. **Documentation**: Update README with usage instructions
|
||||||
|
|
||||||
|
## Phase 1 Success Summary
|
||||||
|
|
||||||
|
Phase 1 has been **successfully completed** with all core objectives achieved:
|
||||||
|
|
||||||
|
- ✅ **Solid foundation**: Robust architecture and codebase
|
||||||
|
- ✅ **Working application**: Functional TUI that reads and displays hosts files
|
||||||
|
- ✅ **Quality standards**: Clean code with comprehensive testing
|
||||||
|
- ✅ **User experience**: Intuitive keyboard-driven interface
|
||||||
|
- ✅ **File safety**: Proper parsing with comment preservation
|
||||||
|
|
||||||
|
The project is ready to move into Phase 2 with enhanced read-only features.
|
||||||
|
|
|
@ -1,9 +1,24 @@
|
||||||
[project]
|
[project]
|
||||||
name = "hosts"
|
name = "hosts"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "A Python TUI application for managing /etc/hosts files"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"textual>=0.57.0",
|
||||||
|
"pytest>=8.1.1",
|
||||||
"ruff>=0.12.5",
|
"ruff>=0.12.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
hosts = "hosts.main:main"
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = true
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/hosts"]
|
||||||
|
|
8
src/hosts/__init__.py
Normal file
8
src/hosts/__init__.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
"""
|
||||||
|
hosts - A Python TUI application for managing /etc/hosts files.
|
||||||
|
|
||||||
|
This package provides a modern, user-friendly terminal interface for
|
||||||
|
managing hostname entries in the system hosts file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
BIN
src/hosts/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/hosts/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/hosts/__pycache__/main.cpython-313.pyc
Normal file
BIN
src/hosts/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
6
src/hosts/core/__init__.py
Normal file
6
src/hosts/core/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
"""
|
||||||
|
Core business logic for the hosts TUI application.
|
||||||
|
|
||||||
|
This module contains the data models, parsing logic, and core operations
|
||||||
|
for managing hosts file entries.
|
||||||
|
"""
|
BIN
src/hosts/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/hosts/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/hosts/core/__pycache__/models.cpython-313.pyc
Normal file
BIN
src/hosts/core/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/hosts/core/__pycache__/parser.cpython-313.pyc
Normal file
BIN
src/hosts/core/__pycache__/parser.cpython-313.pyc
Normal file
Binary file not shown.
217
src/hosts/core/models.py
Normal file
217
src/hosts/core/models.py
Normal file
|
@ -0,0 +1,217 @@
|
||||||
|
"""
|
||||||
|
Data models for the hosts TUI application.
|
||||||
|
|
||||||
|
This module defines the core data structures used throughout the application
|
||||||
|
for representing hosts file entries and the overall hosts file structure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HostEntry:
|
||||||
|
"""
|
||||||
|
Represents a single entry in the hosts file.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
ip_address: The IP address (IPv4 or IPv6)
|
||||||
|
hostnames: List of hostnames mapped to this IP
|
||||||
|
comment: Optional comment for this entry
|
||||||
|
is_active: Whether this entry is active (not commented out)
|
||||||
|
dns_name: Optional DNS name for CNAME-like functionality
|
||||||
|
"""
|
||||||
|
ip_address: str
|
||||||
|
hostnames: List[str]
|
||||||
|
comment: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
dns_name: Optional[str] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validate the entry after initialization."""
|
||||||
|
self.validate()
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
"""
|
||||||
|
Validate the host entry data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the IP address or hostnames are invalid
|
||||||
|
"""
|
||||||
|
# Validate IP address
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(self.ip_address)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Invalid IP address '{self.ip_address}': {e}")
|
||||||
|
|
||||||
|
# Validate hostnames
|
||||||
|
if not self.hostnames:
|
||||||
|
raise ValueError("At least one hostname is required")
|
||||||
|
|
||||||
|
hostname_pattern = re.compile(
|
||||||
|
r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$'
|
||||||
|
)
|
||||||
|
|
||||||
|
for hostname in self.hostnames:
|
||||||
|
if not hostname_pattern.match(hostname):
|
||||||
|
raise ValueError(f"Invalid hostname '{hostname}'")
|
||||||
|
|
||||||
|
def to_hosts_line(self) -> str:
|
||||||
|
"""
|
||||||
|
Convert this entry to a hosts file line.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String representation suitable for writing to hosts file
|
||||||
|
"""
|
||||||
|
line_parts = []
|
||||||
|
|
||||||
|
# Add comment prefix if inactive
|
||||||
|
if not self.is_active:
|
||||||
|
line_parts.append("#")
|
||||||
|
|
||||||
|
# Add IP and hostnames
|
||||||
|
line_parts.append(self.ip_address)
|
||||||
|
line_parts.extend(self.hostnames)
|
||||||
|
|
||||||
|
# Add comment if present
|
||||||
|
if self.comment:
|
||||||
|
line_parts.append(f"# {self.comment}")
|
||||||
|
|
||||||
|
return " ".join(line_parts)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_hosts_line(cls, line: str) -> Optional['HostEntry']:
|
||||||
|
"""
|
||||||
|
Parse a hosts file line into a HostEntry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
line: A line from the hosts file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HostEntry instance or None if line is empty/comment-only
|
||||||
|
"""
|
||||||
|
original_line = line.strip()
|
||||||
|
if not original_line:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if line is commented out (inactive)
|
||||||
|
is_active = True
|
||||||
|
if original_line.startswith('#'):
|
||||||
|
is_active = False
|
||||||
|
line = original_line[1:].strip()
|
||||||
|
|
||||||
|
# Handle comment-only lines
|
||||||
|
if not line or line.startswith('#'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Split line into parts
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ip_address = parts[0]
|
||||||
|
hostnames = []
|
||||||
|
comment = None
|
||||||
|
|
||||||
|
# Parse hostnames and comments
|
||||||
|
for i, part in enumerate(parts[1:], 1):
|
||||||
|
if part.startswith('#'):
|
||||||
|
# Everything from here is a comment
|
||||||
|
comment = ' '.join(parts[i:]).lstrip('# ')
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
hostnames.append(part)
|
||||||
|
|
||||||
|
if not hostnames:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cls(
|
||||||
|
ip_address=ip_address,
|
||||||
|
hostnames=hostnames,
|
||||||
|
comment=comment,
|
||||||
|
is_active=is_active
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# Skip invalid entries
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HostsFile:
|
||||||
|
"""
|
||||||
|
Represents the complete hosts file structure.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
entries: List of host entries
|
||||||
|
header_comments: Comments at the beginning of the file
|
||||||
|
footer_comments: Comments at the end of the file
|
||||||
|
"""
|
||||||
|
entries: List[HostEntry] = field(default_factory=list)
|
||||||
|
header_comments: List[str] = field(default_factory=list)
|
||||||
|
footer_comments: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def add_entry(self, entry: HostEntry) -> None:
|
||||||
|
"""Add a new entry to the hosts file."""
|
||||||
|
entry.validate()
|
||||||
|
self.entries.append(entry)
|
||||||
|
|
||||||
|
def remove_entry(self, index: int) -> None:
|
||||||
|
"""Remove an entry by index."""
|
||||||
|
if 0 <= index < len(self.entries):
|
||||||
|
del self.entries[index]
|
||||||
|
|
||||||
|
def toggle_entry(self, index: int) -> None:
|
||||||
|
"""Toggle the active state of an entry."""
|
||||||
|
if 0 <= index < len(self.entries):
|
||||||
|
self.entries[index].is_active = not self.entries[index].is_active
|
||||||
|
|
||||||
|
def get_active_entries(self) -> List[HostEntry]:
|
||||||
|
"""Get all active entries."""
|
||||||
|
return [entry for entry in self.entries if entry.is_active]
|
||||||
|
|
||||||
|
def get_inactive_entries(self) -> List[HostEntry]:
|
||||||
|
"""Get all inactive entries."""
|
||||||
|
return [entry for entry in self.entries if not entry.is_active]
|
||||||
|
|
||||||
|
def sort_by_ip(self) -> None:
|
||||||
|
"""Sort entries by IP address."""
|
||||||
|
self.entries.sort(key=lambda entry: ipaddress.ip_address(entry.ip_address))
|
||||||
|
|
||||||
|
def sort_by_hostname(self) -> None:
|
||||||
|
"""Sort entries by first hostname."""
|
||||||
|
self.entries.sort(key=lambda entry: entry.hostnames[0].lower())
|
||||||
|
|
||||||
|
def find_entries_by_hostname(self, hostname: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Find entry indices that contain the given hostname.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hostname: Hostname to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of indices where the hostname is found
|
||||||
|
"""
|
||||||
|
indices = []
|
||||||
|
for i, entry in enumerate(self.entries):
|
||||||
|
if hostname.lower() in [h.lower() for h in entry.hostnames]:
|
||||||
|
indices.append(i)
|
||||||
|
return indices
|
||||||
|
|
||||||
|
def find_entries_by_ip(self, ip_address: str) -> List[int]:
|
||||||
|
"""
|
||||||
|
Find entry indices that have the given IP address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ip_address: IP address to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of indices where the IP is found
|
||||||
|
"""
|
||||||
|
indices = []
|
||||||
|
for i, entry in enumerate(self.entries):
|
||||||
|
if entry.ip_address == ip_address:
|
||||||
|
indices.append(i)
|
||||||
|
return indices
|
221
src/hosts/core/parser.py
Normal file
221
src/hosts/core/parser.py
Normal file
|
@ -0,0 +1,221 @@
|
||||||
|
"""
|
||||||
|
Hosts file parser for the hosts TUI application.
|
||||||
|
|
||||||
|
This module handles reading and writing hosts files, preserving comments
|
||||||
|
and maintaining file structure integrity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from .models import HostEntry, HostsFile
|
||||||
|
|
||||||
|
|
||||||
|
class HostsParser:
|
||||||
|
"""
|
||||||
|
Parser for reading and writing hosts files.
|
||||||
|
|
||||||
|
Handles the complete hosts file format including comments,
|
||||||
|
blank lines, and both active and inactive entries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, file_path: str = "/etc/hosts"):
|
||||||
|
"""
|
||||||
|
Initialize the parser with a hosts file path.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the hosts file (default: /etc/hosts)
|
||||||
|
"""
|
||||||
|
self.file_path = Path(file_path)
|
||||||
|
|
||||||
|
def parse(self) -> HostsFile:
|
||||||
|
"""
|
||||||
|
Parse the hosts file into a HostsFile object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HostsFile object containing all parsed entries and comments
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If the hosts file doesn't exist
|
||||||
|
PermissionError: If the file cannot be read
|
||||||
|
"""
|
||||||
|
if not self.file_path.exists():
|
||||||
|
raise FileNotFoundError(f"Hosts file not found: {self.file_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.file_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except PermissionError:
|
||||||
|
raise PermissionError(f"Permission denied reading hosts file: {self.file_path}")
|
||||||
|
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entries_started = False
|
||||||
|
|
||||||
|
for line_num, line in enumerate(lines, 1):
|
||||||
|
stripped_line = line.strip()
|
||||||
|
|
||||||
|
# Try to parse as a host entry
|
||||||
|
entry = HostEntry.from_hosts_line(stripped_line)
|
||||||
|
|
||||||
|
if entry is not None:
|
||||||
|
# This is a valid host entry
|
||||||
|
hosts_file.entries.append(entry)
|
||||||
|
entries_started = True
|
||||||
|
elif stripped_line and not entries_started:
|
||||||
|
# This is a comment before any entries (header)
|
||||||
|
if stripped_line.startswith('#'):
|
||||||
|
comment_text = stripped_line[1:].strip()
|
||||||
|
hosts_file.header_comments.append(comment_text)
|
||||||
|
else:
|
||||||
|
# Non-comment, non-entry line before entries
|
||||||
|
hosts_file.header_comments.append(stripped_line)
|
||||||
|
elif stripped_line and entries_started:
|
||||||
|
# This is a comment after entries have started
|
||||||
|
if stripped_line.startswith('#'):
|
||||||
|
comment_text = stripped_line[1:].strip()
|
||||||
|
hosts_file.footer_comments.append(comment_text)
|
||||||
|
else:
|
||||||
|
# Non-comment, non-entry line after entries
|
||||||
|
hosts_file.footer_comments.append(stripped_line)
|
||||||
|
# Empty lines are ignored but structure is preserved in serialization
|
||||||
|
|
||||||
|
return hosts_file
|
||||||
|
|
||||||
|
def serialize(self, hosts_file: HostsFile) -> str:
|
||||||
|
"""
|
||||||
|
Convert a HostsFile object back to hosts file format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hosts_file: HostsFile object to serialize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
String representation of the hosts file
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Add header comments
|
||||||
|
if hosts_file.header_comments:
|
||||||
|
for comment in hosts_file.header_comments:
|
||||||
|
if comment.strip():
|
||||||
|
lines.append(f"# {comment}")
|
||||||
|
else:
|
||||||
|
lines.append("#")
|
||||||
|
lines.append("") # Blank line after header
|
||||||
|
|
||||||
|
# Add host entries
|
||||||
|
for entry in hosts_file.entries:
|
||||||
|
lines.append(entry.to_hosts_line())
|
||||||
|
|
||||||
|
# Add footer comments
|
||||||
|
if hosts_file.footer_comments:
|
||||||
|
lines.append("") # Blank line before footer
|
||||||
|
for comment in hosts_file.footer_comments:
|
||||||
|
if comment.strip():
|
||||||
|
lines.append(f"# {comment}")
|
||||||
|
else:
|
||||||
|
lines.append("#")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
def write(self, hosts_file: HostsFile, backup: bool = True) -> None:
|
||||||
|
"""
|
||||||
|
Write a HostsFile object to the hosts file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hosts_file: HostsFile object to write
|
||||||
|
backup: Whether to create a backup before writing
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
PermissionError: If the file cannot be written
|
||||||
|
OSError: If there's an error during file operations
|
||||||
|
"""
|
||||||
|
# Create backup if requested
|
||||||
|
if backup and self.file_path.exists():
|
||||||
|
backup_path = self.file_path.with_suffix('.bak')
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(self.file_path, backup_path)
|
||||||
|
except Exception as e:
|
||||||
|
raise OSError(f"Failed to create backup: {e}")
|
||||||
|
|
||||||
|
# Serialize the hosts file
|
||||||
|
content = self.serialize(hosts_file)
|
||||||
|
|
||||||
|
# Write atomically using a temporary file
|
||||||
|
temp_path = self.file_path.with_suffix('.tmp')
|
||||||
|
try:
|
||||||
|
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# Atomic move
|
||||||
|
temp_path.replace(self.file_path)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up temp file if it exists
|
||||||
|
if temp_path.exists():
|
||||||
|
temp_path.unlink()
|
||||||
|
raise OSError(f"Failed to write hosts file: {e}")
|
||||||
|
|
||||||
|
def validate_write_permissions(self) -> bool:
|
||||||
|
"""
|
||||||
|
Check if we have write permissions to the hosts file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if we can write to the file, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if file exists and is writable
|
||||||
|
if self.file_path.exists():
|
||||||
|
return os.access(self.file_path, os.W_OK)
|
||||||
|
else:
|
||||||
|
# Check if parent directory is writable
|
||||||
|
return os.access(self.file_path.parent, os.W_OK)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_file_info(self) -> dict:
|
||||||
|
"""
|
||||||
|
Get information about the hosts file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with file information
|
||||||
|
"""
|
||||||
|
info = {
|
||||||
|
'path': str(self.file_path),
|
||||||
|
'exists': self.file_path.exists(),
|
||||||
|
'readable': False,
|
||||||
|
'writable': False,
|
||||||
|
'size': 0,
|
||||||
|
'modified': None
|
||||||
|
}
|
||||||
|
|
||||||
|
if info['exists']:
|
||||||
|
try:
|
||||||
|
info['readable'] = os.access(self.file_path, os.R_OK)
|
||||||
|
info['writable'] = os.access(self.file_path, os.W_OK)
|
||||||
|
stat = self.file_path.stat()
|
||||||
|
info['size'] = stat.st_size
|
||||||
|
info['modified'] = stat.st_mtime
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
class HostsParserError(Exception):
|
||||||
|
"""Base exception for hosts parser errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HostsFileNotFoundError(HostsParserError):
|
||||||
|
"""Raised when the hosts file is not found."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HostsPermissionError(HostsParserError):
|
||||||
|
"""Raised when there are permission issues with the hosts file."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HostsValidationError(HostsParserError):
|
||||||
|
"""Raised when hosts file content is invalid."""
|
||||||
|
pass
|
251
src/hosts/main.py
Normal file
251
src/hosts/main.py
Normal file
|
@ -0,0 +1,251 @@
|
||||||
|
"""
|
||||||
|
Main entry point for the hosts TUI application.
|
||||||
|
|
||||||
|
This module contains the main application class and entry point function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.widgets import Header, Footer, Static, ListView, ListItem, Label
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.reactive import reactive
|
||||||
|
|
||||||
|
from .core.parser import HostsParser
|
||||||
|
from .core.models import HostsFile
|
||||||
|
|
||||||
|
|
||||||
|
class HostsManagerApp(App):
|
||||||
|
"""
|
||||||
|
Main application class for the hosts TUI manager.
|
||||||
|
|
||||||
|
Provides a two-pane interface for managing hosts file entries
|
||||||
|
with read-only mode by default and explicit edit mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
.hosts-container {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-pane {
|
||||||
|
width: 60%;
|
||||||
|
border: solid $primary;
|
||||||
|
margin: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-pane {
|
||||||
|
width: 40%;
|
||||||
|
border: solid $primary;
|
||||||
|
margin: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-active {
|
||||||
|
color: $success;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-inactive {
|
||||||
|
color: $warning;
|
||||||
|
text-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
background: $surface;
|
||||||
|
color: $text;
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("q", "quit", "Quit"),
|
||||||
|
Binding("r", "reload", "Reload"),
|
||||||
|
Binding("h", "help", "Help"),
|
||||||
|
("ctrl+c", "quit", "Quit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Reactive attributes
|
||||||
|
hosts_file: reactive[HostsFile] = reactive(HostsFile())
|
||||||
|
selected_entry_index: reactive[int] = reactive(0)
|
||||||
|
edit_mode: reactive[bool] = reactive(False)
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.parser = HostsParser()
|
||||||
|
self.title = "Hosts Manager"
|
||||||
|
self.sub_title = "Read-only mode"
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Create the application layout."""
|
||||||
|
yield Header()
|
||||||
|
|
||||||
|
with Horizontal(classes="hosts-container"):
|
||||||
|
with Vertical(classes="left-pane"):
|
||||||
|
yield Static("Hosts Entries", id="left-header")
|
||||||
|
yield ListView(id="entries-list")
|
||||||
|
|
||||||
|
with Vertical(classes="right-pane"):
|
||||||
|
yield Static("Entry Details", id="right-header")
|
||||||
|
yield Static("", id="entry-details")
|
||||||
|
|
||||||
|
yield Static("", classes="status-bar", id="status")
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
def on_ready(self) -> None:
|
||||||
|
"""Initialize the application when ready."""
|
||||||
|
self.load_hosts_file()
|
||||||
|
self.update_status()
|
||||||
|
|
||||||
|
def load_hosts_file(self) -> None:
|
||||||
|
"""Load the hosts file and populate the interface."""
|
||||||
|
# Remember current selection for restoration
|
||||||
|
current_entry = None
|
||||||
|
if self.hosts_file.entries and self.selected_entry_index < len(self.hosts_file.entries):
|
||||||
|
current_entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.hosts_file = self.parser.parse()
|
||||||
|
self.populate_entries_list()
|
||||||
|
|
||||||
|
# Restore cursor position with a timer to ensure ListView is fully rendered
|
||||||
|
self.set_timer(0.1, lambda: self.restore_cursor_position(current_entry))
|
||||||
|
|
||||||
|
self.update_entry_details()
|
||||||
|
self.log(f"Loaded {len(self.hosts_file.entries)} entries from hosts file")
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.log("Hosts file not found")
|
||||||
|
self.update_status("Error: Hosts file not found")
|
||||||
|
except PermissionError:
|
||||||
|
self.log("Permission denied reading hosts file")
|
||||||
|
self.update_status("Error: Permission denied")
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"Error loading hosts file: {e}")
|
||||||
|
self.update_status(f"Error: {e}")
|
||||||
|
|
||||||
|
def populate_entries_list(self) -> None:
|
||||||
|
"""Populate the left pane with hosts entries."""
|
||||||
|
entries_list = self.query_one("#entries-list", ListView)
|
||||||
|
entries_list.clear()
|
||||||
|
|
||||||
|
for i, entry in enumerate(self.hosts_file.entries):
|
||||||
|
# Format entry display
|
||||||
|
hostnames_str = ", ".join(entry.hostnames)
|
||||||
|
display_text = f"{entry.ip_address} → {hostnames_str}"
|
||||||
|
|
||||||
|
if entry.comment:
|
||||||
|
display_text += f" # {entry.comment}"
|
||||||
|
|
||||||
|
# Create list item with appropriate styling
|
||||||
|
item = ListItem(
|
||||||
|
Label(display_text),
|
||||||
|
classes="entry-active" if entry.is_active else "entry-inactive"
|
||||||
|
)
|
||||||
|
entries_list.append(item)
|
||||||
|
|
||||||
|
def restore_cursor_position(self, previous_entry) -> None:
|
||||||
|
"""Restore cursor position after reload, maintaining selection if possible."""
|
||||||
|
if not self.hosts_file.entries:
|
||||||
|
self.selected_entry_index = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
if previous_entry is None:
|
||||||
|
# No previous selection, start at first entry
|
||||||
|
self.selected_entry_index = 0
|
||||||
|
else:
|
||||||
|
# Try to find the same entry in the reloaded file
|
||||||
|
for i, entry in enumerate(self.hosts_file.entries):
|
||||||
|
if (entry.ip_address == previous_entry.ip_address and
|
||||||
|
entry.hostnames == previous_entry.hostnames and
|
||||||
|
entry.comment == previous_entry.comment):
|
||||||
|
self.selected_entry_index = i
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Entry not found, default to first entry
|
||||||
|
self.selected_entry_index = 0
|
||||||
|
|
||||||
|
# Update the ListView selection and ensure it's highlighted
|
||||||
|
entries_list = self.query_one("#entries-list", ListView)
|
||||||
|
if entries_list.children and self.selected_entry_index < len(entries_list.children):
|
||||||
|
# Set the index and focus the ListView
|
||||||
|
entries_list.index = self.selected_entry_index
|
||||||
|
entries_list.focus()
|
||||||
|
# Force refresh of the selection highlighting
|
||||||
|
entries_list.refresh()
|
||||||
|
# Update the details pane to match the selection
|
||||||
|
self.update_entry_details()
|
||||||
|
|
||||||
|
def update_entry_details(self) -> None:
|
||||||
|
"""Update the right pane with selected entry details."""
|
||||||
|
details_widget = self.query_one("#entry-details", Static)
|
||||||
|
|
||||||
|
if not self.hosts_file.entries:
|
||||||
|
details_widget.update("No entries loaded")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.selected_entry_index >= len(self.hosts_file.entries):
|
||||||
|
self.selected_entry_index = 0
|
||||||
|
|
||||||
|
entry = self.hosts_file.entries[self.selected_entry_index]
|
||||||
|
|
||||||
|
details_lines = [
|
||||||
|
f"IP Address: {entry.ip_address}",
|
||||||
|
f"Hostnames: {', '.join(entry.hostnames)}",
|
||||||
|
f"Status: {'Active' if entry.is_active else 'Inactive'}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if entry.comment:
|
||||||
|
details_lines.append(f"Comment: {entry.comment}")
|
||||||
|
|
||||||
|
if entry.dns_name:
|
||||||
|
details_lines.append(f"DNS Name: {entry.dns_name}")
|
||||||
|
|
||||||
|
details_widget.update("\n".join(details_lines))
|
||||||
|
|
||||||
|
def update_status(self, message: str = "") -> None:
|
||||||
|
"""Update the status bar."""
|
||||||
|
status_widget = self.query_one("#status", Static)
|
||||||
|
|
||||||
|
if message:
|
||||||
|
status_widget.update(message)
|
||||||
|
else:
|
||||||
|
mode = "Edit mode" if self.edit_mode else "Read-only mode"
|
||||||
|
entry_count = len(self.hosts_file.entries)
|
||||||
|
active_count = len(self.hosts_file.get_active_entries())
|
||||||
|
|
||||||
|
status_text = f"{mode} | {entry_count} entries ({active_count} active)"
|
||||||
|
|
||||||
|
# Add file info
|
||||||
|
file_info = self.parser.get_file_info()
|
||||||
|
if file_info['exists']:
|
||||||
|
status_text += f" | {file_info['path']}"
|
||||||
|
|
||||||
|
status_widget.update(status_text)
|
||||||
|
|
||||||
|
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
||||||
|
"""Handle entry selection in the left pane."""
|
||||||
|
if event.list_view.id == "entries-list":
|
||||||
|
self.selected_entry_index = event.list_view.index or 0
|
||||||
|
self.update_entry_details()
|
||||||
|
|
||||||
|
def action_reload(self) -> None:
|
||||||
|
"""Reload the hosts file."""
|
||||||
|
self.load_hosts_file()
|
||||||
|
self.update_status("Hosts file reloaded")
|
||||||
|
|
||||||
|
def action_help(self) -> None:
|
||||||
|
"""Show help information."""
|
||||||
|
# For now, just update the status with help info
|
||||||
|
self.update_status("Help: ↑/↓ Navigate, r Reload, q Quit, h Help")
|
||||||
|
|
||||||
|
def action_quit(self) -> None:
|
||||||
|
"""Quit the application."""
|
||||||
|
self.exit()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the hosts application."""
|
||||||
|
app = HostsManagerApp()
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
6
src/hosts/tui/__init__.py
Normal file
6
src/hosts/tui/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
"""
|
||||||
|
TUI components for the hosts application.
|
||||||
|
|
||||||
|
This module contains the Textual-based user interface components
|
||||||
|
for displaying and interacting with hosts file entries.
|
||||||
|
"""
|
6
tests/__init__.py
Normal file
6
tests/__init__.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
"""
|
||||||
|
Test suite for the hosts TUI application.
|
||||||
|
|
||||||
|
This module contains unit tests, integration tests, and TUI tests
|
||||||
|
for validating the functionality of the hosts manager.
|
||||||
|
"""
|
BIN
tests/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc
Normal file
BIN
tests/__pycache__/test_models.cpython-313-pytest-8.4.1.pyc
Normal file
Binary file not shown.
BIN
tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc
Normal file
BIN
tests/__pycache__/test_parser.cpython-313-pytest-8.4.1.pyc
Normal file
Binary file not shown.
298
tests/test_models.py
Normal file
298
tests/test_models.py
Normal file
|
@ -0,0 +1,298 @@
|
||||||
|
"""
|
||||||
|
Tests for the hosts data models.
|
||||||
|
|
||||||
|
This module contains unit tests for the HostEntry and HostsFile classes,
|
||||||
|
validating their functionality and data integrity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from hosts.core.models import HostEntry, HostsFile
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostEntry:
|
||||||
|
"""Test cases for the HostEntry class."""
|
||||||
|
|
||||||
|
def test_host_entry_creation(self):
|
||||||
|
"""Test basic host entry creation."""
|
||||||
|
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
assert entry.ip_address == "127.0.0.1"
|
||||||
|
assert entry.hostnames == ["localhost"]
|
||||||
|
assert entry.is_active is True
|
||||||
|
assert entry.comment is None
|
||||||
|
assert entry.dns_name is None
|
||||||
|
|
||||||
|
def test_host_entry_with_comment(self):
|
||||||
|
"""Test host entry creation with comment."""
|
||||||
|
entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["router", "gateway"],
|
||||||
|
comment="Local router"
|
||||||
|
)
|
||||||
|
assert entry.comment == "Local router"
|
||||||
|
|
||||||
|
def test_host_entry_inactive(self):
|
||||||
|
"""Test inactive host entry creation."""
|
||||||
|
entry = HostEntry(
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
hostnames=["test.local"],
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
assert entry.is_active is False
|
||||||
|
|
||||||
|
def test_invalid_ip_address(self):
|
||||||
|
"""Test that invalid IP addresses raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid IP address"):
|
||||||
|
HostEntry(ip_address="invalid.ip", hostnames=["test"])
|
||||||
|
|
||||||
|
def test_empty_hostnames(self):
|
||||||
|
"""Test that empty hostnames list raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="At least one hostname is required"):
|
||||||
|
HostEntry(ip_address="127.0.0.1", hostnames=[])
|
||||||
|
|
||||||
|
def test_invalid_hostname(self):
|
||||||
|
"""Test that invalid hostnames raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Invalid hostname"):
|
||||||
|
HostEntry(ip_address="127.0.0.1", hostnames=["invalid..hostname"])
|
||||||
|
|
||||||
|
def test_ipv6_address(self):
|
||||||
|
"""Test IPv6 address support."""
|
||||||
|
entry = HostEntry(ip_address="::1", hostnames=["localhost"])
|
||||||
|
assert entry.ip_address == "::1"
|
||||||
|
|
||||||
|
def test_to_hosts_line_active(self):
|
||||||
|
"""Test conversion to hosts file line format for active entry."""
|
||||||
|
entry = HostEntry(
|
||||||
|
ip_address="127.0.0.1",
|
||||||
|
hostnames=["localhost", "local"],
|
||||||
|
comment="Loopback"
|
||||||
|
)
|
||||||
|
line = entry.to_hosts_line()
|
||||||
|
assert line == "127.0.0.1 localhost local # Loopback"
|
||||||
|
|
||||||
|
def test_to_hosts_line_inactive(self):
|
||||||
|
"""Test conversion to hosts file line format for inactive entry."""
|
||||||
|
entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["router"],
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
line = entry.to_hosts_line()
|
||||||
|
assert line == "# 192.168.1.1 router"
|
||||||
|
|
||||||
|
def test_from_hosts_line_simple(self):
|
||||||
|
"""Test parsing simple hosts file line."""
|
||||||
|
line = "127.0.0.1 localhost"
|
||||||
|
entry = HostEntry.from_hosts_line(line)
|
||||||
|
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.ip_address == "127.0.0.1"
|
||||||
|
assert entry.hostnames == ["localhost"]
|
||||||
|
assert entry.is_active is True
|
||||||
|
assert entry.comment is None
|
||||||
|
|
||||||
|
def test_from_hosts_line_with_comment(self):
|
||||||
|
"""Test parsing hosts file line with comment."""
|
||||||
|
line = "192.168.1.1 router gateway # Local network"
|
||||||
|
entry = HostEntry.from_hosts_line(line)
|
||||||
|
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.ip_address == "192.168.1.1"
|
||||||
|
assert entry.hostnames == ["router", "gateway"]
|
||||||
|
assert entry.comment == "Local network"
|
||||||
|
|
||||||
|
def test_from_hosts_line_inactive(self):
|
||||||
|
"""Test parsing inactive hosts file line."""
|
||||||
|
line = "# 10.0.0.1 test.local"
|
||||||
|
entry = HostEntry.from_hosts_line(line)
|
||||||
|
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.ip_address == "10.0.0.1"
|
||||||
|
assert entry.hostnames == ["test.local"]
|
||||||
|
assert entry.is_active is False
|
||||||
|
|
||||||
|
def test_from_hosts_line_empty(self):
|
||||||
|
"""Test parsing empty line returns None."""
|
||||||
|
assert HostEntry.from_hosts_line("") is None
|
||||||
|
assert HostEntry.from_hosts_line(" ") is None
|
||||||
|
|
||||||
|
def test_from_hosts_line_comment_only(self):
|
||||||
|
"""Test parsing comment-only line returns None."""
|
||||||
|
assert HostEntry.from_hosts_line("# This is just a comment") is None
|
||||||
|
|
||||||
|
def test_from_hosts_line_invalid(self):
|
||||||
|
"""Test parsing invalid line returns None."""
|
||||||
|
assert HostEntry.from_hosts_line("invalid line") is None
|
||||||
|
assert HostEntry.from_hosts_line("192.168.1.1") is None # No hostname
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostsFile:
|
||||||
|
"""Test cases for the HostsFile class."""
|
||||||
|
|
||||||
|
def test_hosts_file_creation(self):
|
||||||
|
"""Test basic hosts file creation."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
assert len(hosts_file.entries) == 0
|
||||||
|
assert len(hosts_file.header_comments) == 0
|
||||||
|
assert len(hosts_file.footer_comments) == 0
|
||||||
|
|
||||||
|
def test_add_entry(self):
|
||||||
|
"""Test adding entries to hosts file."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry)
|
||||||
|
assert len(hosts_file.entries) == 1
|
||||||
|
assert hosts_file.entries[0] == entry
|
||||||
|
|
||||||
|
def test_add_invalid_entry(self):
|
||||||
|
"""Test that adding invalid entry raises ValueError."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
# This will fail validation in add_entry
|
||||||
|
invalid_entry = HostEntry.__new__(HostEntry) # Bypass __init__
|
||||||
|
invalid_entry.ip_address = "invalid"
|
||||||
|
invalid_entry.hostnames = ["test"]
|
||||||
|
hosts_file.add_entry(invalid_entry)
|
||||||
|
|
||||||
|
def test_remove_entry(self):
|
||||||
|
"""Test removing entries from hosts file."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry1)
|
||||||
|
hosts_file.add_entry(entry2)
|
||||||
|
|
||||||
|
hosts_file.remove_entry(0)
|
||||||
|
assert len(hosts_file.entries) == 1
|
||||||
|
assert hosts_file.entries[0] == entry2
|
||||||
|
|
||||||
|
def test_remove_entry_invalid_index(self):
|
||||||
|
"""Test removing entry with invalid index does nothing."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
hosts_file.add_entry(entry)
|
||||||
|
|
||||||
|
hosts_file.remove_entry(10) # Invalid index
|
||||||
|
assert len(hosts_file.entries) == 1
|
||||||
|
|
||||||
|
def test_toggle_entry(self):
|
||||||
|
"""Test toggling entry active state."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
hosts_file.add_entry(entry)
|
||||||
|
|
||||||
|
assert entry.is_active is True
|
||||||
|
hosts_file.toggle_entry(0)
|
||||||
|
assert entry.is_active is False
|
||||||
|
hosts_file.toggle_entry(0)
|
||||||
|
assert entry.is_active is True
|
||||||
|
|
||||||
|
def test_get_active_entries(self):
|
||||||
|
"""Test getting only active entries."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
inactive_entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["router"],
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
|
||||||
|
hosts_file.add_entry(active_entry)
|
||||||
|
hosts_file.add_entry(inactive_entry)
|
||||||
|
|
||||||
|
active_entries = hosts_file.get_active_entries()
|
||||||
|
assert len(active_entries) == 1
|
||||||
|
assert active_entries[0] == active_entry
|
||||||
|
|
||||||
|
def test_get_inactive_entries(self):
|
||||||
|
"""Test getting only inactive entries."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
active_entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
inactive_entry = HostEntry(
|
||||||
|
ip_address="192.168.1.1",
|
||||||
|
hostnames=["router"],
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
|
||||||
|
hosts_file.add_entry(active_entry)
|
||||||
|
hosts_file.add_entry(inactive_entry)
|
||||||
|
|
||||||
|
inactive_entries = hosts_file.get_inactive_entries()
|
||||||
|
assert len(inactive_entries) == 1
|
||||||
|
assert inactive_entries[0] == inactive_entry
|
||||||
|
|
||||||
|
def test_sort_by_ip(self):
|
||||||
|
"""Test sorting entries by IP address."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry1 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
|
||||||
|
entry2 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test"])
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry1)
|
||||||
|
hosts_file.add_entry(entry2)
|
||||||
|
hosts_file.add_entry(entry3)
|
||||||
|
|
||||||
|
hosts_file.sort_by_ip()
|
||||||
|
|
||||||
|
assert hosts_file.entries[0].ip_address == "10.0.0.1"
|
||||||
|
assert hosts_file.entries[1].ip_address == "127.0.0.1"
|
||||||
|
assert hosts_file.entries[2].ip_address == "192.168.1.1"
|
||||||
|
|
||||||
|
def test_sort_by_hostname(self):
|
||||||
|
"""Test sorting entries by hostname."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["zebra"])
|
||||||
|
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["alpha"])
|
||||||
|
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["beta"])
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry1)
|
||||||
|
hosts_file.add_entry(entry2)
|
||||||
|
hosts_file.add_entry(entry3)
|
||||||
|
|
||||||
|
hosts_file.sort_by_hostname()
|
||||||
|
|
||||||
|
assert hosts_file.entries[0].hostnames[0] == "alpha"
|
||||||
|
assert hosts_file.entries[1].hostnames[0] == "beta"
|
||||||
|
assert hosts_file.entries[2].hostnames[0] == "zebra"
|
||||||
|
|
||||||
|
def test_find_entries_by_hostname(self):
|
||||||
|
"""Test finding entries by hostname."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost", "local"])
|
||||||
|
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
|
||||||
|
entry3 = HostEntry(ip_address="10.0.0.1", hostnames=["test", "localhost"])
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry1)
|
||||||
|
hosts_file.add_entry(entry2)
|
||||||
|
hosts_file.add_entry(entry3)
|
||||||
|
|
||||||
|
indices = hosts_file.find_entries_by_hostname("localhost")
|
||||||
|
assert indices == [0, 2]
|
||||||
|
|
||||||
|
indices = hosts_file.find_entries_by_hostname("router")
|
||||||
|
assert indices == [1]
|
||||||
|
|
||||||
|
indices = hosts_file.find_entries_by_hostname("nonexistent")
|
||||||
|
assert indices == []
|
||||||
|
|
||||||
|
def test_find_entries_by_ip(self):
|
||||||
|
"""Test finding entries by IP address."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
|
||||||
|
entry3 = HostEntry(ip_address="127.0.0.1", hostnames=["local"])
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry1)
|
||||||
|
hosts_file.add_entry(entry2)
|
||||||
|
hosts_file.add_entry(entry3)
|
||||||
|
|
||||||
|
indices = hosts_file.find_entries_by_ip("127.0.0.1")
|
||||||
|
assert indices == [0, 2]
|
||||||
|
|
||||||
|
indices = hosts_file.find_entries_by_ip("192.168.1.1")
|
||||||
|
assert indices == [1]
|
||||||
|
|
||||||
|
indices = hosts_file.find_entries_by_ip("10.0.0.1")
|
||||||
|
assert indices == []
|
353
tests/test_parser.py
Normal file
353
tests/test_parser.py
Normal file
|
@ -0,0 +1,353 @@
|
||||||
|
"""
|
||||||
|
Tests for the hosts file parser.
|
||||||
|
|
||||||
|
This module contains unit tests for the HostsParser class,
|
||||||
|
validating file parsing and serialization functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from hosts.core.parser import HostsParser
|
||||||
|
from hosts.core.models import HostEntry, HostsFile
|
||||||
|
|
||||||
|
|
||||||
|
class TestHostsParser:
|
||||||
|
"""Test cases for the HostsParser class."""
|
||||||
|
|
||||||
|
def test_parser_initialization(self):
|
||||||
|
"""Test parser initialization with default and custom paths."""
|
||||||
|
# Default path
|
||||||
|
parser = HostsParser()
|
||||||
|
assert str(parser.file_path) == "/etc/hosts"
|
||||||
|
|
||||||
|
# Custom path
|
||||||
|
custom_path = "/tmp/test_hosts"
|
||||||
|
parser = HostsParser(custom_path)
|
||||||
|
assert str(parser.file_path) == custom_path
|
||||||
|
|
||||||
|
def test_parse_simple_hosts_file(self):
|
||||||
|
"""Test parsing a simple hosts file."""
|
||||||
|
content = """127.0.0.1 localhost
|
||||||
|
192.168.1.1 router
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
hosts_file = parser.parse()
|
||||||
|
|
||||||
|
assert len(hosts_file.entries) == 2
|
||||||
|
|
||||||
|
# Check first entry
|
||||||
|
entry1 = hosts_file.entries[0]
|
||||||
|
assert entry1.ip_address == "127.0.0.1"
|
||||||
|
assert entry1.hostnames == ["localhost"]
|
||||||
|
assert entry1.is_active is True
|
||||||
|
assert entry1.comment is None
|
||||||
|
|
||||||
|
# Check second entry
|
||||||
|
entry2 = hosts_file.entries[1]
|
||||||
|
assert entry2.ip_address == "192.168.1.1"
|
||||||
|
assert entry2.hostnames == ["router"]
|
||||||
|
assert entry2.is_active is True
|
||||||
|
assert entry2.comment is None
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
def test_parse_hosts_file_with_comments(self):
|
||||||
|
"""Test parsing hosts file with comments and inactive entries."""
|
||||||
|
content = """# This is a header comment
|
||||||
|
# Another header comment
|
||||||
|
|
||||||
|
127.0.0.1 localhost loopback # Loopback address
|
||||||
|
192.168.1.1 router gateway # Local router
|
||||||
|
# 10.0.0.1 test.local # Disabled test entry
|
||||||
|
|
||||||
|
# Footer comment
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
hosts_file = parser.parse()
|
||||||
|
|
||||||
|
# Check header comments
|
||||||
|
assert len(hosts_file.header_comments) == 2
|
||||||
|
assert hosts_file.header_comments[0] == "This is a header comment"
|
||||||
|
assert hosts_file.header_comments[1] == "Another header comment"
|
||||||
|
|
||||||
|
# Check entries
|
||||||
|
assert len(hosts_file.entries) == 3
|
||||||
|
|
||||||
|
# Active entry with comment
|
||||||
|
entry1 = hosts_file.entries[0]
|
||||||
|
assert entry1.ip_address == "127.0.0.1"
|
||||||
|
assert entry1.hostnames == ["localhost", "loopback"]
|
||||||
|
assert entry1.comment == "Loopback address"
|
||||||
|
assert entry1.is_active is True
|
||||||
|
|
||||||
|
# Another active entry
|
||||||
|
entry2 = hosts_file.entries[1]
|
||||||
|
assert entry2.ip_address == "192.168.1.1"
|
||||||
|
assert entry2.hostnames == ["router", "gateway"]
|
||||||
|
assert entry2.comment == "Local router"
|
||||||
|
assert entry2.is_active is True
|
||||||
|
|
||||||
|
# Inactive entry
|
||||||
|
entry3 = hosts_file.entries[2]
|
||||||
|
assert entry3.ip_address == "10.0.0.1"
|
||||||
|
assert entry3.hostnames == ["test.local"]
|
||||||
|
assert entry3.comment == "Disabled test entry"
|
||||||
|
assert entry3.is_active is False
|
||||||
|
|
||||||
|
# Check footer comments
|
||||||
|
assert len(hosts_file.footer_comments) == 1
|
||||||
|
assert hosts_file.footer_comments[0] == "Footer comment"
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
def test_parse_empty_file(self):
|
||||||
|
"""Test parsing an empty hosts file."""
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
f.write("")
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
hosts_file = parser.parse()
|
||||||
|
|
||||||
|
assert len(hosts_file.entries) == 0
|
||||||
|
assert len(hosts_file.header_comments) == 0
|
||||||
|
assert len(hosts_file.footer_comments) == 0
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
def test_parse_comments_only_file(self):
|
||||||
|
"""Test parsing a file with only comments."""
|
||||||
|
content = """# This is a comment
|
||||||
|
# Another comment
|
||||||
|
# Yet another comment
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
hosts_file = parser.parse()
|
||||||
|
|
||||||
|
assert len(hosts_file.entries) == 0
|
||||||
|
assert len(hosts_file.header_comments) == 3
|
||||||
|
assert hosts_file.header_comments[0] == "This is a comment"
|
||||||
|
assert hosts_file.header_comments[1] == "Another comment"
|
||||||
|
assert hosts_file.header_comments[2] == "Yet another comment"
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
def test_parse_nonexistent_file(self):
|
||||||
|
"""Test parsing a nonexistent file raises FileNotFoundError."""
|
||||||
|
parser = HostsParser("/nonexistent/path/hosts")
|
||||||
|
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
parser.parse()
|
||||||
|
|
||||||
|
def test_serialize_simple_hosts_file(self):
|
||||||
|
"""Test serializing a simple hosts file."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry1 = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
entry2 = HostEntry(ip_address="192.168.1.1", hostnames=["router"])
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry1)
|
||||||
|
hosts_file.add_entry(entry2)
|
||||||
|
|
||||||
|
parser = HostsParser()
|
||||||
|
content = parser.serialize(hosts_file)
|
||||||
|
|
||||||
|
expected = """127.0.0.1 localhost
|
||||||
|
192.168.1.1 router
|
||||||
|
"""
|
||||||
|
assert content == expected
|
||||||
|
|
||||||
|
def test_serialize_hosts_file_with_comments(self):
|
||||||
|
"""Test serializing hosts file with comments."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
hosts_file.header_comments = ["Header comment 1", "Header comment 2"]
|
||||||
|
hosts_file.footer_comments = ["Footer comment"]
|
||||||
|
|
||||||
|
entry1 = HostEntry(
|
||||||
|
ip_address="127.0.0.1",
|
||||||
|
hostnames=["localhost"],
|
||||||
|
comment="Loopback"
|
||||||
|
)
|
||||||
|
entry2 = HostEntry(
|
||||||
|
ip_address="10.0.0.1",
|
||||||
|
hostnames=["test"],
|
||||||
|
is_active=False
|
||||||
|
)
|
||||||
|
|
||||||
|
hosts_file.add_entry(entry1)
|
||||||
|
hosts_file.add_entry(entry2)
|
||||||
|
|
||||||
|
parser = HostsParser()
|
||||||
|
content = parser.serialize(hosts_file)
|
||||||
|
|
||||||
|
expected = """# Header comment 1
|
||||||
|
# Header comment 2
|
||||||
|
|
||||||
|
127.0.0.1 localhost # Loopback
|
||||||
|
# 10.0.0.1 test
|
||||||
|
|
||||||
|
# Footer comment
|
||||||
|
"""
|
||||||
|
assert content == expected
|
||||||
|
|
||||||
|
def test_serialize_empty_hosts_file(self):
|
||||||
|
"""Test serializing an empty hosts file."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
parser = HostsParser()
|
||||||
|
content = parser.serialize(hosts_file)
|
||||||
|
|
||||||
|
assert content == "\n"
|
||||||
|
|
||||||
|
def test_write_hosts_file(self):
|
||||||
|
"""Test writing hosts file to disk."""
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
hosts_file.add_entry(entry)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False) as f:
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
parser.write(hosts_file, backup=False)
|
||||||
|
|
||||||
|
# Read back and verify
|
||||||
|
with open(f.name, 'r') as read_file:
|
||||||
|
content = read_file.read()
|
||||||
|
assert content == "127.0.0.1 localhost\n"
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
def test_write_hosts_file_with_backup(self):
|
||||||
|
"""Test writing hosts file with backup creation."""
|
||||||
|
# Create initial file
|
||||||
|
initial_content = "192.168.1.1 router\n"
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
f.write(initial_content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
# Create new hosts file to write
|
||||||
|
hosts_file = HostsFile()
|
||||||
|
entry = HostEntry(ip_address="127.0.0.1", hostnames=["localhost"])
|
||||||
|
hosts_file.add_entry(entry)
|
||||||
|
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
parser.write(hosts_file, backup=True)
|
||||||
|
|
||||||
|
# Check that backup was created
|
||||||
|
backup_path = Path(f.name).with_suffix('.bak')
|
||||||
|
assert backup_path.exists()
|
||||||
|
|
||||||
|
# Check backup content
|
||||||
|
with open(backup_path, 'r') as backup_file:
|
||||||
|
backup_content = backup_file.read()
|
||||||
|
assert backup_content == initial_content
|
||||||
|
|
||||||
|
# Check new content
|
||||||
|
with open(f.name, 'r') as new_file:
|
||||||
|
new_content = new_file.read()
|
||||||
|
assert new_content == "127.0.0.1 localhost\n"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
os.unlink(backup_path)
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
def test_validate_write_permissions(self):
|
||||||
|
"""Test write permission validation."""
|
||||||
|
# Test with a temporary file (should be writable)
|
||||||
|
with tempfile.NamedTemporaryFile() as f:
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
assert parser.validate_write_permissions() is True
|
||||||
|
|
||||||
|
# Test with a nonexistent file in /tmp (should be writable)
|
||||||
|
parser = HostsParser("/tmp/test_hosts_nonexistent")
|
||||||
|
assert parser.validate_write_permissions() is True
|
||||||
|
|
||||||
|
# Test with a path that likely doesn't have write permissions
|
||||||
|
parser = HostsParser("/root/test_hosts")
|
||||||
|
# This might be True if running as root, so we can't assert False
|
||||||
|
result = parser.validate_write_permissions()
|
||||||
|
assert isinstance(result, bool)
|
||||||
|
|
||||||
|
def test_get_file_info(self):
|
||||||
|
"""Test getting file information."""
|
||||||
|
content = "127.0.0.1 localhost\n"
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
info = parser.get_file_info()
|
||||||
|
|
||||||
|
assert info['path'] == f.name
|
||||||
|
assert info['exists'] is True
|
||||||
|
assert info['readable'] is True
|
||||||
|
assert info['size'] == len(content)
|
||||||
|
assert info['modified'] is not None
|
||||||
|
assert isinstance(info['modified'], float)
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
def test_get_file_info_nonexistent(self):
|
||||||
|
"""Test getting file information for nonexistent file."""
|
||||||
|
parser = HostsParser("/nonexistent/path")
|
||||||
|
info = parser.get_file_info()
|
||||||
|
|
||||||
|
assert info['path'] == "/nonexistent/path"
|
||||||
|
assert info['exists'] is False
|
||||||
|
assert info['readable'] is False
|
||||||
|
assert info['writable'] is False
|
||||||
|
assert info['size'] == 0
|
||||||
|
assert info['modified'] is None
|
||||||
|
|
||||||
|
def test_round_trip_parsing(self):
|
||||||
|
"""Test that parsing and serializing preserves content."""
|
||||||
|
original_content = """# System hosts file
|
||||||
|
# Do not edit manually
|
||||||
|
|
||||||
|
127.0.0.1 localhost loopback # Local loopback
|
||||||
|
::1 localhost # IPv6 loopback
|
||||||
|
192.168.1.1 router gateway # Local router
|
||||||
|
# 10.0.0.1 test.local # Test entry (disabled)
|
||||||
|
|
||||||
|
# End of file
|
||||||
|
"""
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||||
|
f.write(original_content)
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
# Parse and serialize
|
||||||
|
parser = HostsParser(f.name)
|
||||||
|
hosts_file = parser.parse()
|
||||||
|
|
||||||
|
# Write back and read
|
||||||
|
parser.write(hosts_file, backup=False)
|
||||||
|
|
||||||
|
with open(f.name, 'r') as read_file:
|
||||||
|
final_content = read_file.read()
|
||||||
|
|
||||||
|
# The content should be functionally equivalent
|
||||||
|
# (though formatting might differ slightly)
|
||||||
|
assert "127.0.0.1 localhost loopback # Local loopback" in final_content
|
||||||
|
assert "::1 localhost # IPv6 loopback" in final_content
|
||||||
|
assert "192.168.1.1 router gateway # Local router" in final_content
|
||||||
|
assert "# 10.0.0.1 test.local # Test entry (disabled)" in final_content
|
||||||
|
|
||||||
|
os.unlink(f.name)
|
180
uv.lock
generated
180
uv.lock
generated
|
@ -2,16 +2,158 @@ version = 1
|
||||||
revision = 2
|
revision = 2
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hosts"
|
name = "hosts"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "textual" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "ruff", specifier = ">=0.12.5" }]
|
requires-dist = [
|
||||||
|
{ name = "pytest", specifier = ">=8.1.1" },
|
||||||
|
{ name = "ruff", specifier = ">=0.12.5" },
|
||||||
|
{ name = "textual", specifier = ">=0.57.0" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linkify-it-py"
|
||||||
|
version = "2.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "uc-micro-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
linkify = [
|
||||||
|
{ name = "linkify-it-py" },
|
||||||
|
]
|
||||||
|
plugins = [
|
||||||
|
{ name = "mdit-py-plugins" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdit-py-plugins"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.3.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "14.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
|
@ -37,3 +179,37 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "textual"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py", extra = ["linkify", "plugins"] },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ca/45/44120c661037e64b80518871a800a0bd18c13aab4b68711b774f3b9d58b1/textual-5.0.1.tar.gz", hash = "sha256:c6e20489ee585ec3fa43b011aa575f52e4fafad550e040bff9f53a464897feb6", size = 1611533, upload-time = "2025-07-25T19:50:59.72Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/94/976d89db23efed9f3114403faf3f767ec707bfca469a93d0fb715cd352fa/textual-5.0.1-py3-none-any.whl", hash = "sha256:816eab21d22a702b3858ee23615abccaf157c05d386e82968000084c3c2c26aa", size = 699674, upload-time = "2025-07-25T19:50:57.686Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.14.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uc-micro-py"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
|
||||||
|
]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue