diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml index 96b81e2f..53350bdc 100644 --- a/.codacy/codacy.yaml +++ b/.codacy/codacy.yaml @@ -1,5 +1,7 @@ runtimes: - node@22.2.0 + - python@3.11.11 tools: - eslint@9.3.0 - trivy@0.59.1 + - pylint@3.3.6 diff --git a/cmd/analyze.go b/cmd/analyze.go index 2f5b0115..9cc5ad4d 100644 --- a/cmd/analyze.go +++ b/cmd/analyze.go @@ -91,7 +91,7 @@ type Pattern struct { } func init() { - analyzeCmd.Flags().StringVarP(&outputFile, "output", "o", "", "output file for the results") + analyzeCmd.Flags().StringVarP(&outputFile, "output", "o", "", "Output file for analysis results") analyzeCmd.Flags().StringVarP(&toolToAnalyze, "tool", "t", "", "Which tool to run analysis with") analyzeCmd.Flags().StringVar(&outputFormat, "format", "", "Output format (use 'sarif' for SARIF format)") analyzeCmd.Flags().BoolVar(&autoFix, "fix", false, "Apply auto fix to your issues when available") @@ -203,6 +203,15 @@ func runTrivyAnalysis(workDirectory string, pathsToCheck []string, outputFile st } } +func runPylintAnalysis(workDirectory string, pathsToCheck []string, outputFile string, outputFormat string) { + pylint := config.Config.Tools()["pylint"] + + err := tools.RunPylint(workDirectory, pylint, pathsToCheck, outputFile, outputFormat) + if err != nil { + log.Fatalf("Error running Pylint: %v", err) + } +} + var analyzeCmd = &cobra.Command{ Use: "analyze", Short: "Runs all linters.", @@ -226,6 +235,8 @@ var analyzeCmd = &cobra.Command{ runEslintAnalysis(workDirectory, args, autoFix, outputFile, outputFormat) case "trivy": runTrivyAnalysis(workDirectory, args, outputFile, outputFormat) + case "pylint": + runPylintAnalysis(workDirectory, args, outputFile, outputFormat) case "": log.Fatal("You need to specify a tool to run analysis with, e.g., '--tool eslint'") default: diff --git a/cmd/init.go b/cmd/init.go index 039a8775..692138a6 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -75,6 +75,7 @@ func configFileTemplate(tools []tools.Tool) string { // Default versions eslintVersion := "9.3.0" trivyVersion := "0.59.1" // Latest stable version + pylintVersion := "3.3.6" for _, tool := range tools { if tool.Uuid == "f8b29663-2cb2-498d-b923-a10c6a8c05cd" { @@ -83,6 +84,9 @@ func configFileTemplate(tools []tools.Tool) string { if tool.Uuid == "2fd7fbe0-33f9-4ab3-ab73-e9b62404e2cb" { trivyVersion = tool.Version } + if tool.Uuid == "31677b6d-4ae0-4f56-8041-606a8d7a8e61" { + pylintVersion = tool.Version + } } return fmt.Sprintf(`runtimes: @@ -91,7 +95,8 @@ func configFileTemplate(tools []tools.Tool) string { tools: - eslint@%s - trivy@%s -`, eslintVersion, trivyVersion) + - pylint@%s +`, eslintVersion, trivyVersion, pylintVersion) } func buildRepositoryConfigurationFiles(token string) error { diff --git a/config/tools-installer.go b/config/tools-installer.go index ab841453..be615180 100644 --- a/config/tools-installer.go +++ b/config/tools-installer.go @@ -44,8 +44,16 @@ func InstallTool(name string, toolInfo *plugins.ToolInfo) error { return installDownloadBasedTool(toolInfo) } - // This is a runtime-based tool, proceed with regular installation + // Handle Python tools differently + if toolInfo.Runtime == "python" { + return installPythonTool(name, toolInfo) + } + + // Handle other runtime-based tools + return installRuntimeTool(name, toolInfo) +} +func installRuntimeTool(name string, toolInfo *plugins.ToolInfo) error { // Get the runtime for this tool runtimeInfo, ok := Config.Runtimes()[toolInfo.Runtime] if !ok { @@ -159,6 +167,38 @@ func installDownloadBasedTool(toolInfo *plugins.ToolInfo) error { return nil } +func installPythonTool(name string, toolInfo *plugins.ToolInfo) error { + log.Printf("Installing %s v%s...\n", toolInfo.Name, toolInfo.Version) + + runtimeInfo, ok := Config.Runtimes()[toolInfo.Runtime] + if !ok { + return fmt.Errorf("required runtime %s not found for tool %s", toolInfo.Runtime, name) + } + + pythonBinary, ok := runtimeInfo.Binaries["python3"] + if !ok { + return fmt.Errorf("python3 binary not found in runtime") + } + + // Create venv + cmd := exec.Command(pythonBinary, "-m", "venv", filepath.Join(toolInfo.InstallDir, "venv")) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to create venv: %s\nError: %w", string(output), err) + } + + // Install the tool using pip from venv + pipPath := filepath.Join(toolInfo.InstallDir, "venv", "bin", "pip") + cmd = exec.Command(pipPath, "install", fmt.Sprintf("%s==%s", toolInfo.Name, toolInfo.Version)) + output, err = cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to install tool: %s\nError: %w", string(output), err) + } + + log.Printf("Successfully installed %s v%s\n", toolInfo.Name, toolInfo.Version) + return nil +} + // isToolInstalled checks if a tool is already installed by checking for the binary func isToolInstalled(toolInfo *plugins.ToolInfo) bool { // If there are no binaries, check the install directory diff --git a/plugins/runtime-utils.go b/plugins/runtime-utils.go index b6660dab..23ce4a45 100644 --- a/plugins/runtime-utils.go +++ b/plugins/runtime-utils.go @@ -254,6 +254,11 @@ func (p *runtimePlugin) getDownloadURL(version string) string { // GetInstallationDirectoryPath returns the installation directory path for the runtime func (p *runtimePlugin) getInstallationDirectoryPath(runtimesDir string, version string) string { + // For Python, we want to use a simpler directory structure + if p.Config.Name == "python" { + return path.Join(runtimesDir, "python") + } + // For other runtimes, keep using the filename-based directory fileName := p.getFileName(version) return path.Join(runtimesDir, fileName) } diff --git a/plugins/runtimes/python/plugin.yaml b/plugins/runtimes/python/plugin.yaml index f261c9fa..1d495858 100644 --- a/plugins/runtimes/python/plugin.yaml +++ b/plugins/runtimes/python/plugin.yaml @@ -18,6 +18,6 @@ download: "windows": "pc-windows-msvc" binaries: - name: python3 - path: "python/bin/python3" + path: "bin/python3" - name: pip - path: "python/bin/pip" \ No newline at end of file + path: "bin/pip" \ No newline at end of file diff --git a/plugins/tools/pylint/plugin.yaml b/plugins/tools/pylint/plugin.yaml new file mode 100644 index 00000000..ff3e979c --- /dev/null +++ b/plugins/tools/pylint/plugin.yaml @@ -0,0 +1,16 @@ +name: pylint +description: Python linter +runtime: python +runtime_binaries: + package_manager: python3 + execution: python3 +binaries: + - name: python + path: "venv/bin/python3" +formatters: + - name: json + flag: "--output-format=json" +output_options: + file_flag: "--output" +analysis_options: + default_path: "." \ No newline at end of file diff --git a/tools/pylintRunner.go b/tools/pylintRunner.go new file mode 100644 index 00000000..5508bd25 --- /dev/null +++ b/tools/pylintRunner.go @@ -0,0 +1,80 @@ +package tools + +import ( + "codacy/cli-v2/plugins" + "codacy/cli-v2/utils" + "fmt" + "os" + "os/exec" + "path/filepath" +) + +func RunPylint(workDirectory string, toolInfo *plugins.ToolInfo, files []string, outputFile string, outputFormat string) error { + // Get Python binary from venv + pythonPath := filepath.Join(toolInfo.InstallDir, "venv", "bin", "python3") + + // Construct base command with -m pylint to run pylint module + args := []string{"-m", "pylint"} + + // Always use JSON output format since we'll convert to SARIF if needed + args = append(args, "--output-format=json") + + // Create a temporary file for JSON output if we need to convert to SARIF + var tempFile string + if outputFormat == "sarif" { + tmp, err := os.CreateTemp("", "pylint-*.json") + if err != nil { + return fmt.Errorf("failed to create temporary file: %w", err) + } + tempFile = tmp.Name() + tmp.Close() + defer os.Remove(tempFile) + args = append(args, fmt.Sprintf("--output=%s", tempFile)) + } else if outputFile != "" { + args = append(args, fmt.Sprintf("--output=%s", outputFile)) + } + + // Add files to analyze - if no files specified, analyze current directory + if len(files) > 0 { + args = append(args, files...) + } else { + args = append(args, ".") + } + + // Create and run command + cmd := exec.Command(pythonPath, args...) + cmd.Dir = workDirectory + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Run the command + err := cmd.Run() + if err != nil { + // Pylint returns non-zero exit code when it finds issues + // We should not treat this as an error + if _, ok := err.(*exec.ExitError); !ok { + return fmt.Errorf("failed to run pylint: %w", err) + } + } + + // If SARIF output is requested, convert JSON to SARIF + if outputFormat == "sarif" { + jsonOutput, err := os.ReadFile(tempFile) + if err != nil { + return fmt.Errorf("failed to read pylint output: %w", err) + } + + sarifOutput := utils.ConvertPylintToSarif(jsonOutput) + + if outputFile != "" { + err = os.WriteFile(outputFile, sarifOutput, 0644) + if err != nil { + return fmt.Errorf("failed to write SARIF output: %w", err) + } + } else { + fmt.Println(string(sarifOutput)) + } + } + + return nil +} diff --git a/utils/sarif.go b/utils/sarif.go new file mode 100644 index 00000000..a2611da2 --- /dev/null +++ b/utils/sarif.go @@ -0,0 +1,171 @@ +package utils + +import ( + "encoding/json" +) + +// PylintIssue represents a single issue in Pylint's JSON output +type PylintIssue struct { + Type string `json:"type"` + Module string `json:"module"` + Obj string `json:"obj"` + Line int `json:"line"` + Column int `json:"column"` + Path string `json:"path"` + Symbol string `json:"symbol"` + Message string `json:"message"` + MessageID string `json:"message-id"` +} + +// SarifReport represents the SARIF report structure +type SarifReport struct { + Version string `json:"version"` + Schema string `json:"$schema"` + Runs []Run `json:"runs"` +} + +type Run struct { + Tool Tool `json:"tool"` + Results []Result `json:"results"` +} + +type Tool struct { + Driver Driver `json:"driver"` +} + +type Driver struct { + Name string `json:"name"` + Version string `json:"version"` + InformationURI string `json:"informationUri"` + Rules []Rule `json:"rules"` +} + +type Rule struct { + ID string `json:"id"` + ShortDescription MessageText `json:"shortDescription"` + Properties map[string]string `json:"properties"` +} + +type Result struct { + RuleID string `json:"ruleId"` + Level string `json:"level"` + Message MessageText `json:"message"` + Locations []Location `json:"locations"` +} + +type Location struct { + PhysicalLocation PhysicalLocation `json:"physicalLocation"` +} + +type PhysicalLocation struct { + ArtifactLocation ArtifactLocation `json:"artifactLocation"` + Region Region `json:"region"` +} + +type ArtifactLocation struct { + URI string `json:"uri"` +} + +type Region struct { + StartLine int `json:"startLine"` + StartColumn int `json:"startColumn"` +} + +type MessageText struct { + Text string `json:"text"` +} + +// ConvertPylintToSarif converts Pylint JSON output to SARIF format +func ConvertPylintToSarif(pylintOutput []byte) []byte { + var issues []PylintIssue + if err := json.Unmarshal(pylintOutput, &issues); err != nil { + // If parsing fails, return empty SARIF report + return createEmptySarifReport() + } + + // Create SARIF report + sarifReport := SarifReport{ + Version: "2.1.0", + Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + Runs: []Run{ + { + Tool: Tool{ + Driver: Driver{ + Name: "Pylint", + Version: "3.3.6", // TODO: Get this dynamically + InformationURI: "https://pylint.org", + }, + }, + Results: make([]Result, 0, len(issues)), + }, + }, + } + + // Convert each Pylint issue to SARIF result + for _, issue := range issues { + result := Result{ + RuleID: issue.Symbol, + Level: getSarifLevel(issue.Type), + Message: MessageText{ + Text: issue.Message, + }, + Locations: []Location{ + { + PhysicalLocation: PhysicalLocation{ + ArtifactLocation: ArtifactLocation{ + URI: issue.Path, + }, + Region: Region{ + StartLine: issue.Line, + StartColumn: issue.Column, + }, + }, + }, + }, + } + sarifReport.Runs[0].Results = append(sarifReport.Runs[0].Results, result) + } + + sarifData, err := json.MarshalIndent(sarifReport, "", " ") + if err != nil { + return createEmptySarifReport() + } + + return sarifData +} + +// getSarifLevel converts Pylint message type to SARIF level +func getSarifLevel(pylintType string) string { + switch pylintType { + case "error", "fatal": + return "error" + case "warning": + return "warning" + case "convention", "refactor": + return "note" + default: + return "none" + } +} + +// createEmptySarifReport creates an empty SARIF report in case of errors +func createEmptySarifReport() []byte { + emptyReport := SarifReport{ + Version: "2.1.0", + Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + Runs: []Run{ + { + Tool: Tool{ + Driver: Driver{ + Name: "Pylint", + Version: "3.3.6", + InformationURI: "https://pylint.org", + }, + }, + Results: []Result{}, + }, + }, + } + sarifData, _ := json.MarshalIndent(emptyReport, "", " ") + return sarifData +}