pintest-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pintest/range_set.py ADDED
@@ -0,0 +1,173 @@
1
+ """Efficient range-based storage for line numbers."""
2
+
3
+ from typing import List, Tuple, Set
4
+
5
+
6
+ class RangeSet:
7
+ """Efficient range-based storage with consistency maintenance."""
8
+
9
+ def __init__(self, ranges: List[Tuple[int, int]] = None):
10
+ """Initialize with optional list of (start, end) ranges."""
11
+ self.ranges: List[Tuple[int, int]] = []
12
+ if ranges:
13
+ for start, end in ranges:
14
+ self.add_range(start, end)
15
+
16
+ def add_lines(self, lines: Set[int]) -> None:
17
+ """Add individual line numbers, maintaining range consistency."""
18
+ for line in sorted(lines):
19
+ self.add_range(line, line)
20
+
21
+ def add_range(self, start: int, end: int) -> None:
22
+ """Add a range, merging with overlapping/adjacent ranges."""
23
+ if start > end:
24
+ start, end = end, start
25
+
26
+ # Find ranges that overlap or are adjacent to new range
27
+ merged_start = start
28
+ merged_end = end
29
+ new_ranges = []
30
+ merged = False
31
+
32
+ for r_start, r_end in self.ranges:
33
+ # Check if ranges overlap or are adjacent (can be merged)
34
+ if r_end < start - 1:
35
+ # This range is completely before new range
36
+ new_ranges.append((r_start, r_end))
37
+ elif r_start > end + 1:
38
+ # This range is completely after new range
39
+ if not merged:
40
+ new_ranges.append((merged_start, merged_end))
41
+ merged = True
42
+ new_ranges.append((r_start, r_end))
43
+ else:
44
+ # Ranges overlap or are adjacent - merge them
45
+ merged_start = min(merged_start, r_start)
46
+ merged_end = max(merged_end, r_end)
47
+
48
+ if not merged:
49
+ new_ranges.append((merged_start, merged_end))
50
+
51
+ self.ranges = new_ranges
52
+
53
+ def remove_lines(self, lines: Set[int]) -> None:
54
+ """Remove individual line numbers, splitting ranges if needed."""
55
+ for line in sorted(lines):
56
+ self.remove_range(line, line)
57
+
58
+ def remove_range(self, start: int, end: int) -> None:
59
+ """Remove a range, splitting existing ranges if needed."""
60
+ if start > end:
61
+ start, end = end, start
62
+
63
+ new_ranges = []
64
+
65
+ for r_start, r_end in self.ranges:
66
+ if r_end < start or r_start > end:
67
+ # No overlap - keep range as is
68
+ new_ranges.append((r_start, r_end))
69
+ else:
70
+ # Overlap - split the range
71
+ if r_start < start:
72
+ # Keep the part before removed range
73
+ new_ranges.append((r_start, start - 1))
74
+ if r_end > end:
75
+ # Keep the part after removed range
76
+ new_ranges.append((end + 1, r_end))
77
+
78
+ self.ranges = new_ranges
79
+
80
+ def contains(self, line: int) -> bool:
81
+ """Check if a line number is covered by any range."""
82
+ # Binary search for efficiency
83
+ left, right = 0, len(self.ranges) - 1
84
+
85
+ while left <= right:
86
+ mid = (left + right) // 2
87
+ start, end = self.ranges[mid]
88
+
89
+ if line < start:
90
+ right = mid - 1
91
+ elif line > end:
92
+ left = mid + 1
93
+ else:
94
+ return True
95
+
96
+ return False
97
+
98
+ def intersects_any(self, line_numbers: Set[int]) -> bool:
99
+ """Check if any of the line numbers are covered."""
100
+ for line in line_numbers:
101
+ if self.contains(line):
102
+ return True
103
+ return False
104
+
105
+ def intersection(self, other: 'RangeSet') -> 'RangeSet':
106
+ """Return intersection of two range sets."""
107
+ result = RangeSet()
108
+
109
+ i = j = 0
110
+ while i < len(self.ranges) and j < len(other.ranges):
111
+ s1, e1 = self.ranges[i]
112
+ s2, e2 = other.ranges[j]
113
+
114
+ # Find overlap
115
+ overlap_start = max(s1, s2)
116
+ overlap_end = min(e1, e2)
117
+
118
+ if overlap_start <= overlap_end:
119
+ result.add_range(overlap_start, overlap_end)
120
+
121
+ # Advance the range that ends first
122
+ if e1 < e2:
123
+ i += 1
124
+ else:
125
+ j += 1
126
+
127
+ return result
128
+
129
+ def union(self, other: 'RangeSet') -> 'RangeSet':
130
+ """Return union of two range sets."""
131
+ result = RangeSet(self.ranges[:])
132
+ for start, end in other.ranges:
133
+ result.add_range(start, end)
134
+ return result
135
+
136
+ def to_list(self) -> List[Tuple[int, int]]:
137
+ """Export as list of (start, end) tuples."""
138
+ return self.ranges[:]
139
+
140
+ def to_compact_string(self) -> str:
141
+ """Export as compact string representation: '1-5,10-12,20'."""
142
+ parts = []
143
+ for start, end in self.ranges:
144
+ if start == end:
145
+ parts.append(str(start))
146
+ else:
147
+ parts.append(f"{start}-{end}")
148
+ return ','.join(parts)
149
+
150
+ @classmethod
151
+ def from_compact_string(cls, s: str) -> 'RangeSet':
152
+ """Import from compact string: '1-5,10-12,20'."""
153
+ if not s or not s.strip():
154
+ return cls()
155
+
156
+ ranges = []
157
+ for part in s.split(','):
158
+ part = part.strip()
159
+ if '-' in part:
160
+ start, end = part.split('-', 1)
161
+ ranges.append((int(start), int(end)))
162
+ else:
163
+ val = int(part)
164
+ ranges.append((val, val))
165
+
166
+ return cls(ranges)
167
+
168
+ def __len__(self) -> int:
169
+ """Return total number of lines covered."""
170
+ return sum(end - start + 1 for start, end in self.ranges)
171
+
172
+ def __repr__(self) -> str:
173
+ return f"RangeSet({self.ranges})"
@@ -0,0 +1,381 @@
1
+ """Range-based test mapping storage for dramatic size reduction.
2
+
3
+ Optimized version that stores line ranges instead of individual lines,
4
+ reducing 12M entries to ~100K ranges (99%+ reduction).
5
+ """
6
+
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import Set, List, Dict, Optional
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+
13
+ from .range_set import RangeSet
14
+
15
+
16
+ def normalize_test_name(test_name: str) -> str:
17
+ """
18
+ Normalize test name by removing parametrization.
19
+
20
+ This aggregates parametrized tests into their base test name:
21
+ - test_foo[param1] -> test_foo
22
+ - test_foo[param2] -> test_foo
23
+
24
+ This prevents:
25
+ 1. Storing duplicate entries for each parameter combination
26
+ 2. Issues with dynamic parameters that change between runs
27
+
28
+ Args:
29
+ test_name: Full test name including parametrization
30
+
31
+ Returns:
32
+ Base test name without parametrization
33
+ """
34
+ # Strip parametrization: test_name[params] -> test_name
35
+ if "[" in test_name and test_name.endswith("]"):
36
+ return test_name.split("[")[0]
37
+ return test_name
38
+
39
+
40
+
41
+ @dataclass
42
+ class TestMapping:
43
+ """Represents a test and the files/lines it covers."""
44
+ test_name: str
45
+ file_path: str
46
+ lines: RangeSet
47
+
48
+
49
+ class TestMappingDBV2:
50
+ """SQLite database with range-based storage for test-to-code mappings."""
51
+
52
+ SCHEMA_VERSION = 2
53
+
54
+ def __init__(self, db_path: Path):
55
+ """
56
+ Initialize mapping database.
57
+
58
+ Args:
59
+ db_path: Path to SQLite database file
60
+ """
61
+ self.db_path = Path(db_path)
62
+ self.conn: Optional[sqlite3.Connection] = None
63
+
64
+ def __enter__(self):
65
+ """Context manager entry."""
66
+ self.connect()
67
+ return self
68
+
69
+ def __exit__(self, exc_type, exc_val, exc_tb):
70
+ """Context manager exit."""
71
+ self.close()
72
+
73
+ def connect(self):
74
+ """Open database connection."""
75
+ self.conn = sqlite3.connect(str(self.db_path))
76
+ self.conn.row_factory = sqlite3.Row
77
+
78
+ # Performance optimizations
79
+ self.conn.execute("PRAGMA journal_mode=WAL")
80
+ self.conn.execute("PRAGMA synchronous=NORMAL")
81
+ self.conn.execute("PRAGMA cache_size=-64000") # 64MB cache
82
+ self.conn.execute("PRAGMA temp_store=MEMORY")
83
+ self.conn.execute("PRAGMA mmap_size=268435456") # 256MB memory-mapped I/O
84
+
85
+ # Auto-initialize schema if not present
86
+ if not self.is_initialized():
87
+ self.initialize_schema()
88
+
89
+ def close(self):
90
+ """Close database connection."""
91
+ if self.conn:
92
+ self.conn.close()
93
+ self.conn = None
94
+
95
+ def initialize_schema(self):
96
+ """Create database tables with range-based storage."""
97
+ cursor = self.conn.cursor()
98
+
99
+ # Metadata table
100
+ cursor.execute("""
101
+ CREATE TABLE IF NOT EXISTS metadata (
102
+ key TEXT PRIMARY KEY,
103
+ value TEXT NOT NULL
104
+ )
105
+ """)
106
+
107
+ # Range-based test coverage table
108
+ cursor.execute("""
109
+ CREATE TABLE IF NOT EXISTS test_coverage_ranges (
110
+ test_name TEXT NOT NULL,
111
+ file_path TEXT NOT NULL,
112
+ ranges TEXT NOT NULL,
113
+ PRIMARY KEY (test_name, file_path)
114
+ )
115
+ """)
116
+
117
+ # Indexes for fast lookups
118
+ cursor.execute("""
119
+ CREATE INDEX IF NOT EXISTS idx_ranges_file_path
120
+ ON test_coverage_ranges(file_path)
121
+ """)
122
+
123
+ cursor.execute("""
124
+ CREATE INDEX IF NOT EXISTS idx_ranges_test_name
125
+ ON test_coverage_ranges(test_name)
126
+ """)
127
+
128
+ # Store schema version
129
+ cursor.execute("""
130
+ INSERT OR REPLACE INTO metadata (key, value)
131
+ VALUES ('schema_version', ?)
132
+ """, (str(self.SCHEMA_VERSION),))
133
+
134
+ # Store creation timestamp
135
+ cursor.execute("""
136
+ INSERT OR IGNORE INTO metadata (key, value)
137
+ VALUES ('created_at', ?)
138
+ """, (datetime.utcnow().isoformat(),))
139
+
140
+ self.conn.commit()
141
+
142
+ def is_initialized(self) -> bool:
143
+ """Check if database schema is initialized."""
144
+ try:
145
+ cursor = self.conn.cursor()
146
+ cursor.execute("""
147
+ SELECT name FROM sqlite_master
148
+ WHERE type='table' AND name='test_coverage_ranges'
149
+ """)
150
+ return cursor.fetchone() is not None
151
+ except sqlite3.Error:
152
+ return False
153
+
154
+ def import_from_coverage(self, coverage_file: Path, incremental: bool = False):
155
+ """
156
+ Import test mappings from pytest .coverage file.
157
+
158
+ Args:
159
+ coverage_file: Path to .coverage SQLite file
160
+ incremental: If True, merge with existing mappings
161
+ """
162
+ from .coverage_mapper import CoverageMapper
163
+
164
+ mapper = CoverageMapper(coverage_file)
165
+ mapper.load_coverage()
166
+
167
+ cursor = self.conn.cursor()
168
+
169
+ # Build range-compressed mappings
170
+ test_file_ranges: Dict[Tuple[str, str], RangeSet] = {}
171
+
172
+ for file_path, coverage_data in mapper.coverage_data.items():
173
+ for line_num, test_contexts in coverage_data.test_contexts.items():
174
+ for test_name in test_contexts:
175
+ # Normalize test name to aggregate parametrized tests
176
+ normalized_test = normalize_test_name(test_name)
177
+
178
+ key = (normalized_test, file_path)
179
+ if key not in test_file_ranges:
180
+ test_file_ranges[key] = RangeSet()
181
+ test_file_ranges[key].add_range(line_num, line_num)
182
+
183
+ if incremental:
184
+ # Merge with existing ranges
185
+ for (test_name, file_path), new_ranges in test_file_ranges.items():
186
+ cursor.execute("""
187
+ SELECT ranges FROM test_coverage_ranges
188
+ WHERE test_name = ? AND file_path = ?
189
+ """, (test_name, file_path))
190
+
191
+ row = cursor.fetchone()
192
+ if row:
193
+ # Merge with existing
194
+ existing = RangeSet.from_compact_string(row['ranges'])
195
+ merged = existing.union(new_ranges)
196
+
197
+ cursor.execute("""
198
+ UPDATE test_coverage_ranges
199
+ SET ranges = ?
200
+ WHERE test_name = ? AND file_path = ?
201
+ """, (merged.to_compact_string(), test_name, file_path))
202
+ else:
203
+ # Insert new
204
+ cursor.execute("""
205
+ INSERT INTO test_coverage_ranges (test_name, file_path, ranges)
206
+ VALUES (?, ?, ?)
207
+ """, (test_name, file_path, new_ranges.to_compact_string()))
208
+ else:
209
+ # Replace all mappings
210
+ cursor.execute("DELETE FROM test_coverage_ranges")
211
+
212
+ records = [
213
+ (test_name, file_path, ranges.to_compact_string())
214
+ for (test_name, file_path), ranges in test_file_ranges.items()
215
+ ]
216
+
217
+ cursor.executemany("""
218
+ INSERT INTO test_coverage_ranges (test_name, file_path, ranges)
219
+ VALUES (?, ?, ?)
220
+ """, records)
221
+
222
+ # Update metadata
223
+ cursor.execute("""
224
+ INSERT OR REPLACE INTO metadata (key, value)
225
+ VALUES ('last_import', ?)
226
+ """, (datetime.utcnow().isoformat(),))
227
+
228
+ cursor.execute("""
229
+ INSERT OR REPLACE INTO metadata (key, value)
230
+ VALUES ('source_coverage_file', ?)
231
+ """, (str(coverage_file.absolute()),))
232
+
233
+ cursor.execute("""
234
+ INSERT OR REPLACE INTO metadata (key, value)
235
+ VALUES ('total_tests', ?)
236
+ """, (str(len(set(k[0] for k in test_file_ranges.keys()))),))
237
+
238
+ cursor.execute("""
239
+ INSERT OR REPLACE INTO metadata (key, value)
240
+ VALUES ('total_files', ?)
241
+ """, (str(len(mapper.coverage_data)),))
242
+
243
+ self.conn.commit()
244
+
245
+ return len(test_file_ranges)
246
+
247
+ def find_tests_for_file_lines(
248
+ self,
249
+ file_path: str,
250
+ line_numbers: Set[int]
251
+ ) -> Set[str]:
252
+ """
253
+ Find all tests that cover specific lines in a file.
254
+
255
+ Args:
256
+ file_path: Path to source file
257
+ line_numbers: Set of line numbers to check
258
+
259
+ Returns:
260
+ Set of test names that cover those lines
261
+ """
262
+ if not line_numbers:
263
+ return set()
264
+
265
+ cursor = self.conn.cursor()
266
+ file_path = str(file_path)
267
+
268
+ # Query for all tests covering this file
269
+ cursor.execute("""
270
+ SELECT test_name, ranges
271
+ FROM test_coverage_ranges
272
+ WHERE file_path = ?
273
+ """, (file_path,))
274
+
275
+ tests = set()
276
+ for row in cursor.fetchall():
277
+ ranges = RangeSet.from_compact_string(row['ranges'])
278
+ if ranges.intersects_any(line_numbers):
279
+ tests.add(row['test_name'])
280
+
281
+ return tests
282
+
283
+ def get_all_test_files(self, file_path: str) -> Set[str]:
284
+ """
285
+ Get all tests that cover any line in a file.
286
+
287
+ Args:
288
+ file_path: Path to source file
289
+
290
+ Returns:
291
+ Set of test names
292
+ """
293
+ cursor = self.conn.cursor()
294
+ file_path = str(file_path)
295
+
296
+ cursor.execute("""
297
+ SELECT DISTINCT test_name
298
+ FROM test_coverage_ranges
299
+ WHERE file_path = ?
300
+ """, (file_path,))
301
+
302
+ return {row['test_name'] for row in cursor.fetchall()}
303
+
304
+ def get_metadata(self) -> Dict[str, str]:
305
+ """Get all metadata key-value pairs."""
306
+ cursor = self.conn.cursor()
307
+ cursor.execute("SELECT key, value FROM metadata")
308
+ return {row['key']: row['value'] for row in cursor.fetchall()}
309
+
310
+ def get_stats(self) -> Dict[str, int]:
311
+ """Get database statistics."""
312
+ cursor = self.conn.cursor()
313
+
314
+ cursor.execute("SELECT COUNT(DISTINCT test_name) FROM test_coverage_ranges")
315
+ total_tests = cursor.fetchone()[0]
316
+
317
+ cursor.execute("SELECT COUNT(DISTINCT file_path) FROM test_coverage_ranges")
318
+ total_files = cursor.fetchone()[0]
319
+
320
+ cursor.execute("SELECT COUNT(*) FROM test_coverage_ranges")
321
+ total_range_entries = cursor.fetchone()[0]
322
+
323
+ # Calculate actual line coverage and compression ratio
324
+ cursor.execute("SELECT ranges FROM test_coverage_ranges")
325
+ total_lines = 0
326
+ total_ranges = 0
327
+ for row in cursor.fetchall():
328
+ rs = RangeSet.from_compact_string(row['ranges'])
329
+ total_lines += len(rs)
330
+ total_ranges += len(rs.ranges)
331
+
332
+ return {
333
+ 'total_tests': total_tests,
334
+ 'total_files': total_files,
335
+ 'total_range_entries': total_range_entries,
336
+ 'total_lines_covered': total_lines,
337
+ 'total_ranges': total_ranges,
338
+ 'compression_ratio': round(total_lines / total_ranges, 2) if total_ranges > 0 else 0,
339
+ # Backward compatibility with old TestMappingDB
340
+ 'total_mappings': total_lines, # Equivalent to total line coverage
341
+ }
342
+
343
+ def get_all_test_names(self) -> set:
344
+ """
345
+ Get all unique test names efficiently.
346
+
347
+ Returns:
348
+ Set of all test names in the database
349
+ """
350
+ cursor = self.conn.cursor()
351
+ # This query benefits from the idx_test_name index
352
+ cursor.execute("SELECT DISTINCT test_name FROM test_coverage_ranges")
353
+ return {row[0] for row in cursor.fetchall()}
354
+
355
+ def optimize(self):
356
+ """Optimize database for query performance."""
357
+ cursor = self.conn.cursor()
358
+ cursor.execute("ANALYZE")
359
+ self.conn.commit()
360
+
361
+ def vacuum(self):
362
+ """Reclaim unused space."""
363
+ self.conn.execute("VACUUM")
364
+
365
+ def is_stale(self, coverage_file: Path, max_age_hours: int = 24) -> bool:
366
+ """Check if mapping is stale compared to coverage file."""
367
+ if coverage_file.exists():
368
+ coverage_mtime = coverage_file.stat().st_mtime
369
+ if self.db_path.exists():
370
+ db_mtime = self.db_path.stat().st_mtime
371
+ if coverage_mtime > db_mtime:
372
+ return True
373
+
374
+ metadata = self.get_metadata()
375
+ if 'last_import' in metadata:
376
+ last_import = datetime.fromisoformat(metadata['last_import'])
377
+ age_hours = (datetime.utcnow() - last_import).total_seconds() / 3600
378
+ if age_hours > max_age_hours:
379
+ return True
380
+
381
+ return False
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Update test mapping database from coverage data.
4
+
5
+ Usage:
6
+ python update_mapping.py --repo-root ~/workspace/myproject
7
+ """
8
+
9
+ import argparse
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ # Add pintest to path
14
+ SCRIPT_DIR = Path(__file__).parent
15
+ sys.path.insert(0, str(SCRIPT_DIR))
16
+
17
+ from pintest.test_mapping_db_v2 import TestMappingDBV2
18
+ from pintest.pre_commit_hook import ensure_docker_containers, run_preflight_scripts
19
+
20
+
21
+ def update_mapping(
22
+ repo_root: Path,
23
+ coverage_file: Path = None,
24
+ mapping_db: Path = None,
25
+ verbose: bool = False
26
+ ):
27
+ """
28
+ Update test mapping database from coverage file.
29
+
30
+ Args:
31
+ repo_root: Repository root directory
32
+ coverage_file: Path to .coverage file
33
+ mapping_db: Path to mapping database file
34
+ verbose: Print verbose output
35
+ """
36
+ repo_root = repo_root.resolve()
37
+
38
+ # Default paths
39
+ if coverage_file is None:
40
+ coverage_file = repo_root / ".coverage"
41
+
42
+ if mapping_db is None:
43
+ mapping_db = repo_root / ".test_mapping.db"
44
+
45
+ # Ensure Docker containers are running (if a compose file is present)
46
+ if verbose:
47
+ print("\nšŸ”§ Pre-checks...")
48
+
49
+ if not ensure_docker_containers(repo_root, verbose):
50
+ print("āŒ Docker container check failed.", file=sys.stderr)
51
+ return 1
52
+
53
+ if not run_preflight_scripts(repo_root, verbose):
54
+ print("āŒ Preflight checks failed.", file=sys.stderr)
55
+ return 1
56
+
57
+ if not coverage_file.exists():
58
+ print(f"āŒ Error: Coverage file not found: {coverage_file}", file=sys.stderr)
59
+ print("\nGenerate coverage first:", file=sys.stderr)
60
+ print(f" cd {repo_root}", file=sys.stderr)
61
+ print(" pytest --cov=src --cov-context=test --cov-report=", file=sys.stderr)
62
+ return 1
63
+
64
+ print(f"šŸ“Š Updating test mapping database...")
65
+ print(f" Coverage file: {coverage_file}")
66
+ print(f" Mapping DB: {mapping_db}")
67
+
68
+ # Initialize database
69
+ with TestMappingDBV2(mapping_db) as db:
70
+ db.initialize_schema()
71
+
72
+ # Import from coverage
73
+ print("\nšŸ“„ Importing coverage data...")
74
+ num_mappings = db.import_from_coverage(coverage_file)
75
+
76
+ # Get stats
77
+ stats = db.get_stats()
78
+ metadata = db.get_metadata()
79
+
80
+ print(f"\nāœ… Test mapping database updated successfully!")
81
+ print(f"\nšŸ“ˆ Statistics:")
82
+ print(f" Total tests: {stats['total_tests']}")
83
+ print(f" Total files: {stats['total_files']}")
84
+ print(f" Total mappings: {stats['total_mappings']}")
85
+ print(f" Last import: {metadata.get('last_import', 'N/A')}")
86
+
87
+ if verbose:
88
+ print(f"\nšŸ—„ļø Database size: {mapping_db.stat().st_size / 1024:.1f} KB")
89
+
90
+ return 0
91
+
92
+
93
+ def main():
94
+ parser = argparse.ArgumentParser(
95
+ description="Update test mapping database from coverage data"
96
+ )
97
+ parser.add_argument(
98
+ "--repo-root",
99
+ type=Path,
100
+ default=Path.cwd(),
101
+ help="Repository root directory (default: current directory)"
102
+ )
103
+ parser.add_argument(
104
+ "--coverage-file",
105
+ type=Path,
106
+ help="Path to .coverage file (default: <repo>/.coverage)"
107
+ )
108
+ parser.add_argument(
109
+ "--mapping-db",
110
+ type=Path,
111
+ help="Path to mapping database (default: <repo>/.test_mapping.db)"
112
+ )
113
+ parser.add_argument(
114
+ "-v", "--verbose",
115
+ action="store_true",
116
+ help="Verbose output"
117
+ )
118
+
119
+ args = parser.parse_args()
120
+
121
+ return update_mapping(
122
+ args.repo_root,
123
+ args.coverage_file,
124
+ args.mapping_db,
125
+ args.verbose
126
+ )
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())