SmokeCrossFit 0.0.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.
crossfit/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ from crossfit import refs
2
+ from crossfit.commands.command import Command
3
+ from crossfit.tools import Tool, Jacoco, DotnetCoverage, create_tool
4
+ from crossfit.executors import Executor, LocalExecutor, create_executor
5
+
6
+ __all__ = [
7
+ 'refs',
8
+ 'Command',
9
+ 'Tool',
10
+ 'Jacoco',
11
+ 'DotnetCoverage',
12
+ 'create_tool',
13
+ 'Executor',
14
+ 'LocalExecutor',
15
+ 'create_executor'
16
+ ]
17
+
Binary file
@@ -0,0 +1,3 @@
1
+ from .command import Command
2
+
3
+ __all__ = ['Command']
@@ -0,0 +1,86 @@
1
+ import copy
2
+ import glob
3
+ from pathlib import Path
4
+ from typing import Optional, List, Tuple, Self
5
+ from typeguard import typechecked
6
+
7
+ COMMAND_DELIMITER = " "
8
+
9
+
10
+ class Command:
11
+ next_command: Optional[Self]
12
+ execution_call: Optional[str]
13
+ command_to_execute: Optional[str]
14
+ command_body: List[str]
15
+ options: List[Tuple[str, Optional[str]]]
16
+ arguments: List[str]
17
+ values_delimiter: Optional[str]
18
+
19
+ @typechecked()
20
+ def __init__(self):
21
+ """
22
+ Initializes the command structure
23
+ """
24
+ self.next_command = None
25
+ self.execution_call = None
26
+ self.command_to_execute = None
27
+ self.command_body = []
28
+ self.options = []
29
+ self.arguments = []
30
+ self.values_delimiter = None
31
+
32
+ @property
33
+ def command(self) -> List[str]:
34
+ """
35
+ :returns: The command itself as a list of strings
36
+ """
37
+ return list(filter(lambda s: s is not None, [self.execution_call, self.command_to_execute, *self.command_body]))
38
+
39
+ @command.setter
40
+ def command(self, value: List[str]):
41
+ """
42
+ Sets the command body from a list of strings
43
+ :param value: The command body as list of strings
44
+ """
45
+ self.options = []
46
+ self.arguments = []
47
+ self.command_body = value
48
+
49
+ def __str__(self) -> str:
50
+ """
51
+ :returns: The command itself as a single string
52
+ """
53
+ return COMMAND_DELIMITER.join(self.command)
54
+
55
+ def validate(self):
56
+ """
57
+ Validates that the command has an execution call set
58
+ :raises AttributeError: If execution call is not set
59
+ """
60
+ if not self.execution_call:
61
+ raise AttributeError(f"Ensure tool's execution call is set. Valued as: '{self.execution_call}'")
62
+
63
+ @staticmethod
64
+ def validate_path(path: Path):
65
+ """
66
+ Validates that the given path exists, supports wildcards
67
+ :param path: The path to validate
68
+ :raises FileNotFoundError: If the path does not exist
69
+ """
70
+ paths = glob.glob(str(path), recursive=True)
71
+ if not paths:
72
+ raise FileNotFoundError(f"Could not recognize given path - '{path}' - does not exist")
73
+
74
+ def __copy__(self) -> Self:
75
+ """
76
+ :returns: A shallow copy of the command
77
+ """
78
+ command_copy = Command()
79
+ command_copy.next_command = copy.copy(self.next_command)
80
+ command_copy.execution_call = self.execution_call
81
+ command_copy.command_to_execute = self.command_to_execute
82
+ command_copy.command_body = self.command_body
83
+ command_copy.options = self.options
84
+ command_copy.arguments = self.arguments
85
+ command_copy.values_delimiter = self.values_delimiter
86
+ return command_copy
@@ -0,0 +1,176 @@
1
+ import copy
2
+ import glob
3
+ import os.path
4
+ from pathlib import Path
5
+ from typing import List, Optional, Self, Tuple
6
+ from typeguard import typechecked
7
+ from crossfit.commands.command import Command
8
+
9
+
10
+ class CommandBuilder:
11
+ """Builder class for constructing Command objects with a fluent interface."""
12
+ _command: Command
13
+
14
+ def __init__(self):
15
+ """
16
+ Initializes a new CommandBuilder instance with an empty Command.
17
+ """
18
+ self._command: Command = Command()
19
+
20
+ @typechecked()
21
+ def with_next_command(self, command: Command):
22
+ """
23
+ Sets the next command to be executed after the current command.
24
+ :param command: The Command object to be executed next
25
+ """
26
+ self._command.next_command = command
27
+
28
+ @typechecked()
29
+ def with_command(self, command: List[str]) -> Self:
30
+ """
31
+ Initializes the command from a list of strings.
32
+ :param command: A list containing at least two strings - execution call and command to execute
33
+ :returns: Self for method chaining
34
+ :raises ValueError: If the command list has fewer than two elements
35
+ """
36
+ if len(command) < 2:
37
+ raise ValueError(
38
+ 'Command construct parameter must have at least two str arguments - execution call and any arguments')
39
+ self._command.execution_call = command[0]
40
+ self._command.command_to_execute = command[1]
41
+ self._command.command_body = command[2:]
42
+ return self
43
+
44
+ @typechecked()
45
+ def set_execution_call(self, execution_call: str, path: Optional[Path] = None) -> Self:
46
+ """
47
+ Sets the execution call for the command.
48
+ :param execution_call: The executable or interpreter to run
49
+ :param path: Optional path to prepend to the execution call
50
+ :returns: Self for method chaining
51
+ """
52
+ self._command.execution_call = execution_call if path is None else os.path.relpath(path / execution_call)
53
+ return self
54
+
55
+ @typechecked()
56
+ def set_command_to_execute(self, command_to_execute: str) -> Self:
57
+ """
58
+ Sets the main command or script to execute.
59
+ :param command_to_execute: The command or script name to execute
60
+ :returns: Self for method chaining
61
+ """
62
+ self._command.command_to_execute = command_to_execute
63
+ return self
64
+
65
+ @typechecked()
66
+ def set_command_body(self, command_body: List[str]) -> Self:
67
+ """
68
+ Sets the command body containing arguments and options.
69
+ :param command_body: A list of strings representing the command body
70
+ :returns: Self for method chaining
71
+ """
72
+ self._command.command_body = command_body
73
+ return self
74
+
75
+ @typechecked()
76
+ def set_values_delimiter(self, delimiter: Optional[str], update_current_values: bool = False) -> Self:
77
+ """
78
+ Sets the delimiter used between options and their values.
79
+ :param delimiter: The delimiter string (e.g., '=' or ':'), or None for space separation
80
+ :param update_current_values: If True, updates all existing options with the new delimiter
81
+ :returns: Self for method chaining
82
+ """
83
+ if self._command.values_delimiter != delimiter:
84
+ self._command.values_delimiter = delimiter
85
+ if update_current_values:
86
+ self.__update_all_options()
87
+ return self
88
+
89
+ @typechecked()
90
+ def add_option(self, option: str, value: Optional[str] = None, delimiter: Optional[str] = None) -> Self:
91
+ """
92
+ Adds a single option to the command.
93
+ :param option: The option flag or name (e.g., '--verbose' or '-v')
94
+ :param value: Optional value for the option
95
+ :param delimiter: Optional delimiter to use for this specific option
96
+ :returns: Self for method chaining
97
+ """
98
+ self.__add_option(option, value, delimiter)
99
+ return self
100
+
101
+ @typechecked()
102
+ def add_options(self, *args: Tuple[str, Optional[str]]) -> Self:
103
+ """
104
+ Adds multiple options to the command.
105
+ :param args: Variable number of tuples, each containing (option, value) pairs
106
+ :returns: Self for method chaining
107
+ """
108
+ for option in args:
109
+ self.__add_option(option[0], option[1])
110
+ return self
111
+
112
+ @typechecked()
113
+ def add_arguments(self, *args) -> Self:
114
+ """
115
+ Adds positional arguments to the command.
116
+ :param args: Variable number of arguments to add
117
+ :returns: Self for method chaining
118
+ """
119
+ self._command.command_body = self._command.command_body[len(self._command.arguments):]
120
+ self._command.arguments.extend(list(args).copy())
121
+ self._command.command_body = self._command.arguments + self._command.command_body
122
+ return self
123
+
124
+ @typechecked()
125
+ def add_path_arguments(self, *paths: Path) -> Self:
126
+ """
127
+ Adds path arguments to the command, resolving glob patterns.
128
+ :param paths: Variable number of Path objects, which may contain glob patterns
129
+ :returns: Self for method chaining
130
+ :raises FileNotFoundError: If a path does not exist and is not a valid glob pattern
131
+ """
132
+ [self._command.validate_path(path) for path in paths]
133
+ resolved_glob_paths = []
134
+ for path in paths:
135
+ resolved_glob_paths.extend(
136
+ os.path.relpath(resolved_path) for resolved_path in glob.glob(str(path), recursive=True))
137
+ self.add_arguments(*[os.path.relpath(path) for path in resolved_glob_paths])
138
+ return self
139
+
140
+ def build_command(self) -> Command:
141
+ """
142
+ Builds and returns a copy of the configured Command object.
143
+ :returns: A copy of the Command object with all configured settings
144
+ """
145
+ return copy.copy(self._command)
146
+
147
+ def __update_all_options(self) -> Self:
148
+ """
149
+ Updates all existing options with the current delimiter.
150
+ :returns: Self for method chaining
151
+ """
152
+ self._command.command_body = self._command.command_body[:-sum(len(option) for option in self._command.options)]
153
+ options = self._command.options.copy()
154
+ self._command.options = []
155
+ self.add_options(*options)
156
+ return self
157
+
158
+ def __add_option(self, option: str, value: Optional[str] = None, delimiter: Optional[str] = None) -> Self:
159
+ """
160
+ Internal method to add an option to the command body.
161
+ :param option: The option flag or name
162
+ :param value: Optional value for the option
163
+ :param delimiter: Optional delimiter to override the default
164
+ :returns: Self for method chaining
165
+ """
166
+ if delimiter is not None:
167
+ self._command.values_delimiter = delimiter
168
+
169
+ if value is not None:
170
+ if self._command.values_delimiter is not None:
171
+ self._command.command_body.append(f"{option}{self._command.values_delimiter}{value}")
172
+ else:
173
+ self._command.command_body.extend([option, value])
174
+ else:
175
+ self._command.command_body.append(option)
176
+ self._command.options.append((option, value))
@@ -0,0 +1,5 @@
1
+ from .executor import Executor
2
+ from .local_executor import LocalExecutor
3
+ from .executor_factory import create_executor
4
+
5
+ __all__ = ['Executor', 'LocalExecutor', 'create_executor']
@@ -0,0 +1,46 @@
1
+ from abc import ABC, abstractmethod
2
+ from logging import Logger
3
+
4
+ from crossfit.commands.command import Command
5
+ from crossfit.models.command_models import CommandResult
6
+
7
+
8
+ class Executor(ABC):
9
+ """Abstract base class for command executors."""
10
+
11
+ def __init__(self, logger: Logger, catch: bool = True):
12
+ """
13
+ :param logger: Logger instance for logging execution details (required)
14
+ :param catch: If True, catches exceptions and returns error in CommandResult.
15
+ If False, re-raises exceptions.
16
+ """
17
+ self._logger = logger
18
+ self._catch = catch
19
+
20
+ def execute(self, command: Command) -> CommandResult:
21
+ """
22
+ Executes the given command and any chained commands via next_command.
23
+ :param command: The Command object to execute
24
+ :returns: Aggregated CommandResult from all executed commands
25
+ """
26
+ result = self._execute_single(command)
27
+
28
+ current = command.next_command
29
+ while current is not None:
30
+ if result.code != 0:
31
+ self._logger.warning(f"Stopping command chain due to failure. Code: {result.code}")
32
+ break
33
+ next_result = self._execute_single(current)
34
+ result = result.add_result(next_result)
35
+ current = current.next_command
36
+
37
+ return result
38
+
39
+ @abstractmethod
40
+ def _execute_single(self, command: Command) -> CommandResult:
41
+ """
42
+ Executes a single command without handling chained commands.
43
+ :param command: The Command object to execute
44
+ :returns: CommandResult with execution details
45
+ """
46
+ raise NotImplementedError
@@ -0,0 +1,8 @@
1
+ from crossfit.models.executor_models import ExecutorType
2
+ from crossfit.executors.local_executor import LocalExecutor
3
+
4
+ def create_executor(executor_type: ExecutorType, logger = None, catch: bool = True, **kwargs):
5
+ if executor_type == ExecutorType.Local:
6
+ return LocalExecutor(logger, catch, **kwargs)
7
+ else:
8
+ raise ValueError(f"Unknown executor type: {executor_type}")
@@ -0,0 +1,95 @@
1
+ import subprocess
2
+ from logging import Logger
3
+ import shlex
4
+ from crossfit.commands.command import Command
5
+ from crossfit.executors.executor import Executor
6
+ from crossfit.models.command_models import CommandResult
7
+
8
+
9
+ class LocalExecutor(Executor):
10
+ """Executor that runs commands locally via subprocess."""
11
+
12
+ def __init__(self, logger: Logger, catch: bool = True, **execution_kwargs):
13
+ """
14
+ :param logger: Logger instance for logging execution details (required)
15
+ :param catch: If True, catches exceptions and returns error in CommandResult.
16
+ If False, re-raises exceptions.
17
+ :param execution_kwargs: Additional arguments passed to subprocess.run
18
+ """
19
+ super().__init__(logger, catch)
20
+ self._exec_kwargs = {
21
+ "capture_output": True,
22
+ "check": True,
23
+ "text": True,
24
+ }
25
+ self._exec_kwargs.update(execution_kwargs)
26
+
27
+ def _execute_single(self, command: Command) -> CommandResult:
28
+ """
29
+ Executes a single command without handling chained commands.
30
+ :param command: The Command object to execute
31
+ :returns: CommandResult with execution details
32
+ """
33
+ command_str = str(command)
34
+ try:
35
+ command.validate()
36
+ res = subprocess.run(shlex.split(command_str), **self._exec_kwargs)
37
+
38
+ if res.returncode != 0 or (res.stderr and len(res.stderr)):
39
+ raise subprocess.CalledProcessError(
40
+ res.returncode, command_str, output=res.stdout, stderr=res.stderr
41
+ )
42
+
43
+ self._logger.info(f"Command '{command_str}' finished with exit code {res.returncode}. {res.stdout}")
44
+ return CommandResult(
45
+ code=res.returncode,
46
+ command=command_str,
47
+ output=res.stdout,
48
+ error=res.stderr,
49
+ )
50
+
51
+ except subprocess.CalledProcessError as cpe:
52
+ self._logger.error(
53
+ f"Execution of command '{command_str}' failed with error: {cpe.stderr}. Return code {cpe.returncode}."
54
+ )
55
+ if not self._catch:
56
+ raise
57
+ return CommandResult(
58
+ code=cpe.returncode,
59
+ command=command_str,
60
+ output=cpe.stdout or "",
61
+ error=cpe.stderr or "",
62
+ )
63
+
64
+ except FileNotFoundError as not_found_e:
65
+ self._logger.error(f"Command '{command_str}' not found. {not_found_e.strerror}")
66
+ if not self._catch:
67
+ raise
68
+ return CommandResult(
69
+ code=124,
70
+ command=command_str,
71
+ output="",
72
+ error=not_found_e.strerror,
73
+ )
74
+
75
+ except AttributeError as attr_e:
76
+ self._logger.error(f"Command validation failed: {attr_e}")
77
+ if not self._catch:
78
+ raise
79
+ return CommandResult(
80
+ code=1,
81
+ command=command_str,
82
+ output="",
83
+ error=str(attr_e),
84
+ )
85
+
86
+ except Exception as e:
87
+ self._logger.error(f"An error occurred while executing command '{command_str}': {e}")
88
+ if not self._catch:
89
+ raise
90
+ return CommandResult(
91
+ code=1,
92
+ command=command_str,
93
+ output="",
94
+ error=str(e),
95
+ )
@@ -0,0 +1,5 @@
1
+ from .command_models import CommandResult
2
+ from .tool_models import ToolType, ReportFormat
3
+ from .executor_models import ExecutorType
4
+
5
+ __all__ = ['CommandResult', 'ToolType', 'ReportFormat', 'ExecutorType']
@@ -0,0 +1,31 @@
1
+ from enum import Enum
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class CommandType(Enum):
8
+ SaveReport = "report"
9
+ SnapshotCoverage = "snapshot"
10
+ MergeCoverage = "merge"
11
+ ResetCoverage = "reset"
12
+
13
+
14
+ class CommandResult(BaseModel):
15
+ code: int
16
+ command: str
17
+ output: Optional[str] = ""
18
+ target: Optional[str] = ""
19
+ error: Optional[str] = ""
20
+
21
+
22
+ def __add__(self, other):
23
+ self.code &= other.code
24
+ self.command += " && " + other.command
25
+ self.output = "\n".join(filter(lambda val: val is not None, (self.output, other.output)))
26
+ self.target = other.target or self.target
27
+ self.error = "\n".join(filter(lambda val: val is not None, (self.error, other.error)))
28
+ return self
29
+
30
+ def add_result(self, other):
31
+ return self + other
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ExecutorType(Enum):
5
+ Local = "Local"
6
+ Remote = "Remote"
@@ -0,0 +1,14 @@
1
+ from enum import Enum
2
+
3
+
4
+ class ToolType(Enum):
5
+ Jacoco = "jacococli.jar"
6
+ DotnetCoverage = "dotnet-coverage"
7
+ DotnetReportGenerator = "reportgenerator"
8
+
9
+
10
+ class ReportFormat(Enum):
11
+ Csv = "Csv"
12
+ Html = "Html"
13
+ Xml = "Xml"
14
+ Cobertura = "Cobertura"
@@ -0,0 +1,9 @@
1
+ from pathlib import Path
2
+
3
+ __filepath = Path(__file__)
4
+ bin_dir = (__filepath.parent.parent / r'bin')
5
+ tools_dir = (bin_dir / r'tools')
6
+ executors_dir = (bin_dir / r'executors')
7
+ deps_dir = (bin_dir / r'deps')
8
+
9
+ __all__ = ['bin_dir', 'tools_dir', 'deps_dir', 'executors_dir']
@@ -0,0 +1,7 @@
1
+ from .dotnet_coverage import DotnetCoverage
2
+ from .jacoco import Jacoco
3
+ from .tool import Tool
4
+ from .tool_factory import create_tool
5
+
6
+ __all__ = ['Tool', 'Jacoco', 'DotnetCoverage', 'create_tool']
7
+
@@ -0,0 +1,77 @@
1
+ from pathlib import Path
2
+ from typing import List, Tuple, Optional
3
+
4
+ from crossfit import Command
5
+ from crossfit.models import ReportFormat, ToolType
6
+ from crossfit.tools.tool import Tool
7
+
8
+
9
+ class DotnetCoverage(Tool):
10
+ _tool_type = ToolType.DotnetCoverage
11
+
12
+ def snapshot_coverage(self, session, target_dir, target_file, *extras: Tuple[str, Optional[str]]) -> Command:
13
+ """
14
+ Triggers dotnet-coverage agent to save cobertura formatted coverage files to the given path.
15
+ :param session: Session id of the dotnet-coverage agent collected-coverage to snapshot.
16
+ :param target_dir: Targeted directory to save the dotnet-coverage collection to.
17
+ :param target_file: Specified snapshot file name - when not given, uses default with .xml suffix.
18
+ :param extras: Extra options to pass to the dotnet-coverage CLI's snapshot command.
19
+ :return: A Command object configured to snapshot coverage data.
20
+ """
21
+ target_path = Path(target_dir) / (
22
+ target_file if target_file is not None else self._get_default_target_filename())
23
+ if not target_path.suffix:
24
+ target_path = target_path.with_suffix(".xml")
25
+ extras += ("--output", str(target_path)),
26
+ command_builder = self._create_command_builder(
27
+ "snapshot", None, None, *extras).add_arguments(session)
28
+
29
+ return command_builder.build_command()
30
+
31
+ def merge_coverage(self, coverage_files, target_dir, target_file, *extras: Tuple[str, Optional[str]]) -> Command:
32
+ """
33
+ Merges multiple coverage files into a single unified coverage file.
34
+ :param coverage_files: File paths to coverage files to merge.
35
+ :param target_dir: Targeted directory to save the merged coverage file to.
36
+ :param target_file: Specified merged file name - when not given, uses default with .xml suffix.
37
+ :param extras: Extra options to pass to the dotnet-coverage CLI's merge command.
38
+ :return: A Command object configured to merge coverage files.
39
+ """
40
+ extras += ("--output", str(Path(target_dir) / (
41
+ target_file if target_file is not None else Path(self._get_default_target_filename()).with_suffix(
42
+ ".xml")))),
43
+ command_builder = self._create_command_builder("merge", None, coverage_files, *extras)
44
+ if {"--output-format", "-f"}.intersection(command_builder.build_command().command):
45
+ command_builder = command_builder.add_option("--output-format", ReportFormat.Cobertura.value.lower())
46
+
47
+ return command_builder.build_command()
48
+
49
+ def save_report(self, coverage_files, target_dir, sourcecode_dir,
50
+ report_format: ReportFormat = None, report_formats: List[ReportFormat] = None,
51
+ *extras: Tuple[str, Optional[str]]) -> Command:
52
+ """
53
+ Creates a dotnet-coverage report from coverage files to the given path.
54
+ :param coverage_files: File paths (can handle wildcards) to create dotnet-coverage report from.
55
+ :param target_dir: Targeted directory to save the dotnet-coverage report to.
56
+ :param sourcecode_dir: Directory containing the covered source code files.
57
+ :param report_format: Primary format of the dotnet-coverage report.
58
+ :param report_formats: Additional formats of dotnet-coverage reports to create.
59
+ :param extras: Extra options to pass to the dotnet CLI's report command.
60
+ :return: A Command object configured to generate the coverage report.
61
+ """
62
+ multiple_values_delimiter = ';'
63
+ if sourcecode_dir:
64
+ extras += ("-sourcedirs", str(sourcecode_dir)),
65
+ extras += ("-targetdir", str(target_dir)),
66
+ command_builder = (
67
+ self._create_command_builder("", ToolType.DotnetReportGenerator, None, *extras)
68
+ .set_values_delimiter(":", True)
69
+ .add_option("-reports", f"\"{multiple_values_delimiter.join(map(str, coverage_files))}\""))
70
+ combined_formats = set((report_formats or []) + [report_format])
71
+ command_builder = command_builder.add_option(
72
+ "-reporttypes",f"\"{multiple_values_delimiter.join([rf.value for rf in combined_formats if rf])}\"")
73
+
74
+ command = command_builder.build_command()
75
+ command.command = [kw.replace("--", "-") for kw in command.command[2:]]
76
+
77
+ return command
@@ -0,0 +1,105 @@
1
+ import os.path
2
+ from pathlib import Path
3
+ from typing import Optional, Tuple
4
+ from crossfit import Command
5
+ from crossfit.commands.command_builder import CommandBuilder
6
+ from crossfit.models.tool_models import ReportFormat, ToolType
7
+ from crossfit.tools.tool import Tool
8
+
9
+
10
+ class Jacoco(Tool):
11
+ """JaCoCo coverage tool implementation for Java projects."""
12
+ _tool_type = ToolType.Jacoco
13
+
14
+ def _create_command_builder(self, command, tool_type = None, path_arguments = None, required_flags = None,
15
+ *extras: Tuple[str, Optional[str]]) -> CommandBuilder:
16
+ """
17
+ Creates a CommandBuilder for JaCoCo CLI commands.
18
+ :param command: The JaCoCo command to execute (e.g., 'report', 'dump', 'merge').
19
+ :param tool_type: The tool type used to build the command on (defaults to self._tool_type).
20
+ :param path_arguments: Path arguments to add to the command (e.g., coverage files).
21
+ :param required_flags: List of required flag options that must be present in extras.
22
+ :param extras: Extra options to pass to the JaCoCo CLI as tuples of (option, value).
23
+ :return: A CommandBuilder configured for the JaCoCo command.
24
+ :raises ValueError: If required flags are missing and catch is False.
25
+ """
26
+ tool_type = tool_type or self._tool_type
27
+ command_builder = (super()._create_command_builder(command, tool_type, path_arguments, *extras)
28
+ .set_execution_call(f"java -jar {os.path.relpath(Path(self._path) / str(tool_type.value))}"))
29
+
30
+ required_flags = required_flags or []
31
+ for required_flag in required_flags:
32
+ if required_flag not in [extra[0] for extra in list(extras)]:
33
+ msg = (f"Encountered error while building {tool_type.name} command. "
34
+ f"JaCoCo flag option {required_flag} is required for command '{command}'.")
35
+ self._logger.error(msg)
36
+ if not self._catch:
37
+ raise ValueError(msg)
38
+ command_builder.set_command_body(["--help"])
39
+
40
+ return command_builder
41
+
42
+ def save_report(self, coverage_files, target_dir, report_format, report_formats, sourcecode_dir, build_dir, *extras)\
43
+ -> Command:
44
+ """
45
+ Creates a JaCoCo coverage report from coverage files to the given path.
46
+ :param coverage_files: File paths to JaCoCo .exec coverage files to create the report from.
47
+ :param target_dir: Targeted directory to save the JaCoCo report to.
48
+ :param report_format: Primary format of the JaCoCo report (e.g., HTML, XML, CSV).
49
+ :param report_formats: Additional formats of JaCoCo reports to create.
50
+ :param sourcecode_dir: Directory containing the covered source code files.
51
+ :param build_dir: Directory containing the compiled class files (required for JaCoCo).
52
+ :param extras: Extra options to pass to the JaCoCo CLI's report command.
53
+ :return: A Command object configured to generate the coverage report.
54
+ """
55
+ if sourcecode_dir:
56
+ extras += ("--sourcefiles", str(sourcecode_dir)),
57
+ if build_dir:
58
+ extras += ("--classfiles", str(build_dir)),
59
+ command = self._create_command_builder(
60
+ "report", None, coverage_files, ["--classfiles"], *extras)
61
+ combined_formats = set((report_formats or []) + [report_format])
62
+ for rf in combined_formats:
63
+ if rf == ReportFormat.Html:
64
+ command = command.add_option(f"--{rf.name.lower()}", str(target_dir))
65
+ elif rf is not None:
66
+ command = command.add_option(f"--{rf.name.lower()}",
67
+ str((Path(target_dir) / self._get_default_target_filename())
68
+ .with_suffix(f".{rf.value.lower()}")))
69
+ return command.build_command()
70
+
71
+ def snapshot_coverage(self, session, target_dir, target_file, *extras) -> Command:
72
+ """
73
+ Triggers JaCoCo agent to dump coverage data to the given path.
74
+ :param session: Session identifier (not used by JaCoCo dump but kept for interface consistency).
75
+ :param target_dir: Targeted directory to save the JaCoCo coverage dump to.
76
+ :param target_file: Specified snapshot file name - when not given, uses default with .exec suffix.
77
+ :param extras: Extra options to pass to the JaCoCo CLI's dump command.
78
+ :return: A Command object configured to dump coverage data.
79
+ """
80
+ target_path = Path(target_dir) / (
81
+ target_file if target_file is not None else self._get_default_target_filename())
82
+ if not target_path.suffix:
83
+ target_path = target_path.with_suffix(".exec")
84
+ extras += ("--destfile", str(target_path)),
85
+ command = self._create_command_builder(
86
+ "dump", None, None, None, *extras)
87
+ return command.build_command()
88
+
89
+ def merge_coverage(self, coverage_files, target_dir, target_file, *extras) -> Command:
90
+ """
91
+ Merges multiple JaCoCo coverage files into a single unified coverage file.
92
+ :param coverage_files: File paths to JaCoCo .exec coverage files to merge.
93
+ :param target_dir: Targeted directory to save the merged coverage file to.
94
+ :param target_file: Specified merged file name - when not given, uses default with .exec suffix.
95
+ :param extras: Extra options to pass to the JaCoCo CLI's merge command.
96
+ :return: A Command object configured to merge coverage files.
97
+ """
98
+ target_path = Path(target_dir) / (
99
+ target_file if target_file is not None else self._get_default_target_filename())
100
+ if not target_path.suffix:
101
+ target_path = target_path.with_suffix(".exec")
102
+ extras += ("--destfile", str(target_path)),
103
+ command = self._create_command_builder(
104
+ "merge", None, coverage_files, None,*extras)
105
+ return command.build_command()
crossfit/tools/tool.py ADDED
@@ -0,0 +1,90 @@
1
+ import tempfile
2
+ import logging
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Collection, Optional, Tuple, List, Union
6
+ from crossfit.commands.command import Command
7
+ from crossfit.commands.command_builder import CommandBuilder
8
+ from crossfit.models.tool_models import ToolType
9
+
10
+
11
+ class Tool(ABC):
12
+ """Abstract base class for coverage tools. Tools build and return Commands."""
13
+ _tool_type: ToolType
14
+ _path: Optional[Path]
15
+ _logger: logging.Logger
16
+ _catch: bool
17
+
18
+ def __init__(self, logger: logging.Logger, path: Optional[Path] = None, catch: bool = True):
19
+ """
20
+ :param logger: Logger instance for logging (required).
21
+ :param path: The path to the tool executable/jar.
22
+ :param catch: If True, catches exceptions and returns fallback. If False, re-raises.
23
+ """
24
+ self._logger: logging.Logger = logger
25
+ self._path: Optional[Path] = path
26
+ self._catch: bool = catch
27
+
28
+ def _get_default_target_filename(self) -> str:
29
+ """Returns the default target filename for this tool."""
30
+ return f"cross-{self._tool_type.name}".lower()
31
+
32
+ def _create_command_builder(self, command: str, tool_type: Optional[ToolType] = None,
33
+ path_arguments: Optional[Collection[Path]] = None,
34
+ *extras: Tuple[str, Optional[str]]) -> CommandBuilder:
35
+ """
36
+ Creates a CommandBuilder for the tool's wanted functionality.
37
+ :param command: The command of the tool to build on.
38
+ :param tool_type: The tool type used to build the command on (defaults to self._tool_type).
39
+ :param path_arguments: Path arguments to add to the command.
40
+ :param extras: Extra options to pass to the CLI's command as tuples of (option, value).
41
+ :returns: A CommandBuilder configured for this tool command.
42
+ """
43
+ tool_type = tool_type or self._tool_type
44
+ path_arguments = path_arguments or []
45
+
46
+ command_builder = CommandBuilder().set_execution_call(str(tool_type.value), self._path)
47
+ try:
48
+ return command_builder.set_command_to_execute(command).add_path_arguments(*path_arguments).add_options(
49
+ *extras)
50
+ except FileNotFoundError as e:
51
+ self._logger.error(f"Encountered exception while building {tool_type.name} command. Error - {e}")
52
+ if not self._catch:
53
+ raise
54
+ return command_builder.set_command_body(["--help"])
55
+
56
+ @abstractmethod
57
+ def save_report(self, coverage_files: List[Union[Path, str]], target_dir: Path, report_format, report_formats,
58
+ sourcecode_dir: Optional[Path], build_dir: Optional[Path],
59
+ *extras: Tuple[str, Optional[str]]) -> Command:
60
+ """Builds a command to create a coverage report."""
61
+ raise NotImplementedError
62
+
63
+ @abstractmethod
64
+ def snapshot_coverage(self, session: str, target_dir: Path, target_file: Optional[Path],
65
+ *extras: Tuple[str, Optional[str]]) -> Command:
66
+ """Builds a command to snapshot coverage data."""
67
+ raise NotImplementedError
68
+
69
+ @abstractmethod
70
+ def merge_coverage(self, coverage_files: List[Union[Path, str]], target_dir: Path, target_file: Optional[Path],
71
+ *extras: Tuple[str, Optional[str]]) -> Command:
72
+ """Builds a command to merge coverage files."""
73
+ raise NotImplementedError
74
+
75
+ def reset_coverage(self, session: str, *extras: Tuple[str, Optional[str]]) -> Command:
76
+ """
77
+ Builds a command to reset coverage data.
78
+ Uses next_command chaining to snapshot with --reset flag and then clean up temp file.
79
+ :param session: Session id of the coverage agent.
80
+ :param extras: Extra options to pass to the CLI's command.
81
+ :returns: A Command with chained cleanup command via next_command.
82
+ """
83
+ target_dir = Path(tempfile.gettempdir()) / r"crossfit"
84
+ extras = extras + (("--reset", None),)
85
+
86
+ snapshot_command = self.snapshot_coverage(session, target_dir, None, *extras)
87
+ cleanup_command = CommandBuilder().with_command(["rm", "-f", str(target_dir)]).build_command()
88
+ snapshot_command.next_command = cleanup_command
89
+
90
+ return snapshot_command
@@ -0,0 +1,20 @@
1
+ import os
2
+ from logging import Logger
3
+ from pathlib import Path
4
+ from typing import Union
5
+
6
+ import crossfit.refs
7
+ from crossfit.tools import Jacoco, DotnetCoverage
8
+ from crossfit.models import ToolType
9
+
10
+
11
+ def create_tool(tool_type: ToolType, tool_path: str = None, cwd: Union[str, os.PathLike] = None, logger: Logger = None,
12
+ catch: bool = True, **kwargs):
13
+ if cwd:
14
+ kwargs["cwd"] = cwd
15
+ if tool_type == ToolType.Jacoco:
16
+ return Jacoco(logger, Path(tool_path) or Path(crossfit.refs.tools_dir), catch)
17
+ elif tool_type == ToolType.DotnetCoverage:
18
+ return DotnetCoverage(logger, Path(tool_path) or Path(crossfit.refs.tools_dir), catch)
19
+ else:
20
+ raise ValueError(f"Unknown tool type: {tool_type}")
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: SmokeCrossFit
3
+ Version: 0.0.0
4
+ Summary: A unified interface for various code coverage tools (JaCoCo, dotnet-coverage)
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+
8
+ # CrossFit Coverage Tools
9
+
10
+ CrossFit is a Python package designed to provide a unified interface for various code coverage tools. It wraps the functionality of different coverage CLI tools, allowing developers to use a consistent API regardless of which underlying tool they prefer.
11
+
12
+ ## Installation
13
+
14
+ To install CrossFit, run:
15
+
16
+ ```bash
17
+ pip install crossfit
18
+ ```
19
+
20
+ ## Prerequisites
21
+
22
+ Before using CrossFit, ensure you have the following installed:
23
+
24
+ 1. Python 3.7 or higher
25
+ 2. Java Runtime Environment (JRE) - Required for JaCoCo integration
26
+ 3. The specific coverage tools you intend to use (e.g., JaCoCo, dotnet-coverage)
27
+
28
+ ## Supported Tools
29
+
30
+ CrossFit currently supports the following coverage tools:
31
+
32
+ 1. **JaCoCo** - Java-based code coverage tool
33
+ 2. **dotnet-coverage** - .NET coverage tool
34
+ 3. **dotnet-reportgenerator** - .NET report generation tool
35
+
36
+ ## Usage
37
+
38
+ CrossFit provides a unified interface for managing code coverage across different tools. Here's how to use it:
39
+
40
+ ### Basic Usage
41
+
42
+ ```python
43
+ from crossfit.tools.jacoco import Jacoco
44
+
45
+ # Initialize the JaCoCo tool
46
+ jacoco = Jacoco(tool_path="/path/to/jacoco")
47
+
48
+ # Save coverage report
49
+ result = jacoco.save_report(
50
+ coverage_files=["coverage.exec"],
51
+ target_dir="/output/directory",
52
+ sourcecode_dir="/source/code"
53
+ )
54
+ ```
55
+
56
+ ### Key Features
57
+
58
+ - **Unified Interface**: Consistent API across different coverage tools
59
+ - **Command Building**: Automatic validation of required flags
60
+ - **Coverage Management**: Merge, snapshot, and save coverage data
61
+ - **Error Handling**: Comprehensive logging and error reporting
62
+ - **Multiple Report Formats**: Support for CSV, HTML, XML, and Cobertura formats
63
+
64
+ ### Example Workflow
65
+
66
+ ```python
67
+ from crossfit.tools.jacoco import Jacoco
68
+ from crossfit.models.tool_models import ReportFormat
69
+
70
+ # Initialize JaCoCo tool
71
+ jacoco = Jacoco(tool_path="/path/to/jacoco")
72
+
73
+ # Merge coverage files
74
+ merge_result = jacoco.merge_coverage(
75
+ coverage_files=["coverage1.exec", "coverage2.exec"],
76
+ target_dir="/output",
77
+ target_file="merged.exec"
78
+ )
79
+
80
+ # Generate HTML report
81
+ report_result = jacoco.save_report(
82
+ coverage_files=["merged.exec"],
83
+ target_dir="/reports",
84
+ sourcecode_dir="/src",
85
+ report_format=ReportFormat.Html
86
+ )
87
+ ```
88
+
89
+ ## Package Structure
90
+
91
+ The package follows a modular structure:
92
+
93
+ - `crossfit/tools/jacoco.py`: Implementation of JaCoCo tool wrapper
94
+ - `crossfit/tools/dotnet_coverage.py`: Implementation of .NET coverage tool wrapper
95
+ - `crossfit/tools/tool.py`: Base tool class and abstract methods
96
+ - `crossfit/models/tool_models.py`: Enum definitions for tool types and report formats
97
+ - `crossfit/models/command_models.py`: Command result models and command types
98
+ - `crossfit/commands/command.py`: Command building and execution utilities
99
+
100
+ ## Core Components
101
+
102
+ ### Tool Models (`crossfit/models/tool_models.py`)
103
+
104
+ Defines the core enumerations used throughout the package:
105
+
106
+ - **ToolType**: Enum representing supported tools
107
+ - `Jacoco`
108
+ - `DotnetCoverage`
109
+ - `DotnetReportGenerator`
110
+
111
+ - **ReportFormat**: Enum representing supported report formats
112
+ - `Csv`
113
+ - `Html`
114
+ - `Xml`
115
+ - `Cobertura`
116
+
117
+ ### Command Models (`crossfit/models/command_models.py`)
118
+
119
+ Provides structured representations of command results:
120
+
121
+ - **CommandType**: Enum defining command types
122
+ - `SaveReport`
123
+ - `SnapshotCoverage`
124
+ - `MergeCoverage`
125
+ - `ResetCoverage`
126
+
127
+ - **CommandResult**: Pydantic model for command execution results with fields:
128
+ - `code`: Exit code from command execution
129
+ - `command`: The executed command string
130
+ - `output`: Standard output from command
131
+ - `target`: Target path/file of operation
132
+ - `error`: Error output from command
133
+
134
+ ### Command Builder (`crossfit/commands/command.py`)
135
+
136
+ Provides utilities for building and executing shell commands:
137
+
138
+ - **Command**: Class for constructing shell commands with:
139
+ - Execution call (e.g., `java -jar`)
140
+ - Command keywords (e.g., `dump`, `merge`, `report`)
141
+ - Options and arguments
142
+ - Path validation
143
+ - Delimiter handling for option-value pairs
144
+
145
+ ## Tool Implementations
146
+
147
+ ### JaCoCo Tool (`crossfit/tools/jacoco.py`)
148
+
149
+ Implements the JaCoCo coverage tool with methods:
150
+
151
+ - `save_report()`: Generate coverage reports in various formats
152
+ - `snapshot_coverage()`: Take coverage snapshots
153
+ - `merge_coverage()`: Merge multiple coverage files
154
+ - `_get_command()`: Build validated JaCoCo commands with required flag checking
155
+
156
+ ### DotNet Coverage Tool (`crossfit/tools/dotnet_coverage.py`)
157
+
158
+ Implements the .NET coverage tool with methods:
159
+
160
+ - `save_report()`: Generate reports from coverage files
161
+ - `merge_coverage()`: Merge coverage data
162
+ - `snapshot_coverage()`: Capture coverage snapshots
163
+ - `reset_coverage()`: Reset coverage data
164
+
165
+ ## Advanced Usage
166
+
167
+ ### Working with Multiple Report Formats
168
+
169
+ ```python
170
+ from crossfit.tools.jacoco import Jacoco
171
+ from crossfit.models.tool_models import ReportFormat
172
+
173
+ jacoco = Jacoco(tool_path="/path/to/jacoco")
174
+
175
+ # Generate multiple report formats
176
+ result = jacoco.save_report(
177
+ coverage_files=["coverage.exec"],
178
+ target_dir="/reports",
179
+ sourcecode_dir="/src",
180
+ report_formats=[ReportFormat.Html, ReportFormat.Xml, ReportFormat.Cobertura]
181
+ )
182
+ ```
183
+
184
+ ### Custom Command Arguments
185
+
186
+ ```python
187
+ from crossfit.tools.jacoco import Jacoco
188
+
189
+ jacoco = Jacoco(tool_path="/path/to/jacoco")
190
+
191
+ # Pass custom arguments to commands
192
+ result = jacoco.merge_coverage(
193
+ ["coverage1.exec", "coverage2.exec"],
194
+ "/output", "merged.exec",
195
+ *["--format", "xml"] # Custom arguments
196
+ )
197
+ ```
@@ -0,0 +1,23 @@
1
+ crossfit/__init__.py,sha256=Bdtz57-tapFnCYiIa9V5k_edCRkXWKqbp13xODBEv4c,382
2
+ crossfit/bin/tools/jacococli.jar,sha256=gRx_jGs1jF1oqJc8-oZ_aJK-emcbaXpLE8S0R-ba91w,607951
3
+ crossfit/commands/__init__.py,sha256=Hiu6s_GoE0cxhtbgnc8rUXPUxRejnHOQAAfhpSHheLI,51
4
+ crossfit/commands/command.py,sha256=0ujP5wvzfA0P9WJfzJrucgGWBEuiZjbxs9oMtdTD8p0,2752
5
+ crossfit/commands/command_builder.py,sha256=GLfvt0zXDQYEirQd8VGOjAUQvnpeslk2KLQp4KMYCAw,7128
6
+ crossfit/executors/__init__.py,sha256=7lUt1jO6RQwY_6xorlXaDBsOLlBradx9bKxzS-VSms0,179
7
+ crossfit/executors/executor.py,sha256=xPDTuP8wb9OQNC44Hy6yy9w9tDTT3NuH6oS_fXs_gp0,1665
8
+ crossfit/executors/executor_factory.py,sha256=xSsTU3f8AgeMiFCwyGM4ZOm3p9Lp--KQRIqNY5ueaZI,389
9
+ crossfit/executors/local_executor.py,sha256=dNkHoY5bQxRQbniec1ZwGKt56QKbZP_qwXSW3avTfno,3435
10
+ crossfit/models/__init__.py,sha256=awbAmN1HTp01PWgTB4JNwlg1EF_9w9ecSxqOaUVme4E,205
11
+ crossfit/models/command_models.py,sha256=0bK7deXh16SRMPtHXTyYmqScAVoRzyycO3uNRgImBHo,818
12
+ crossfit/models/executor_models.py,sha256=9d4UYXKvzIMwf_COBHxg23bvBxJp21A97gJP9oAkvb0,92
13
+ crossfit/models/tool_models.py,sha256=CM-JKXzM563BGverMP4jQFWcqcplA5RXcNJwhoWjHHg,266
14
+ crossfit/refs/__init__.py,sha256=OEUVIOc-CDNp7KmmcDzKfwkz9LA7SlRtrD3Y5rjQIIU,270
15
+ crossfit/tools/__init__.py,sha256=YtIhLrhdEtDOOH_TMGr9_tgt59vxN-kM97I5ajeN9LY,196
16
+ crossfit/tools/dotnet_coverage.py,sha256=1NBkj6Inb-qf3LITciu1248oboMhQTShFqveu-tkW0U,4359
17
+ crossfit/tools/jacoco.py,sha256=oLw895WvaVk_2mSeI3vAh7vWswg-daMzTEnl26tx1cY,5992
18
+ crossfit/tools/tool.py,sha256=OL3xVMf56UdCnahwX1rz-U7qZcXWFo7ep5B0CCu6RFs,4296
19
+ crossfit/tools/tool_factory.py,sha256=Fstp2NlIOsfYY6UX8vefJ7_ClrojnsbOMA7J63dDl58,741
20
+ smokecrossfit-0.0.0.dist-info/METADATA,sha256=aCChRaK7lsbSmxFrrKw_6pREAgJLZlX7J7Chd8G-sEc,5648
21
+ smokecrossfit-0.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
22
+ smokecrossfit-0.0.0.dist-info/top_level.txt,sha256=rDrcypRByvkrDsESoQmYOSkOKMO7eBPRToxHNQflYOI,9
23
+ smokecrossfit-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ crossfit