diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e7b06e576d45ed4641293c3af15918f09f254688..e7820db3dd4e4a7c62f6c368d534be254c1e3daf 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -145,19 +145,19 @@ build-env:
     - docker push $CI_REGISTRY/$CI_PROJECT_PATH/build-env
 
 checkstyle:
-  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
   stage: test
   script:
     - ./gradlew checkstyleMain checkstyleTest
 
 spotbugs:
-  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
   stage: test
   script:
     - ./gradlew :spotbugsMain :core:spotbugsMain :worker:spotbugsMain
 
 test:
-  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
   stage: test
   script:
     - ./gradlew test
@@ -189,7 +189,7 @@ test-coverage:
       cobertura: build/reports/jacoco/jacocoRootReport/cobertura.xml
 
 jar-core:
-  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
   stage: jar
   script:
     - ./gradlew bootJar
@@ -199,7 +199,7 @@ jar-core:
     expire_in: 1h
 
 jar-worker:
-  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:78e2c639424515635266552f286335ff1d09b29505d5830b61b3d0d6b7ce178f
+  image: $CI_REGISTRY/$CI_PROJECT_PATH/build-env@sha256:43ca04a98adaa2dc2c2d4ce5da58a2514f08d10b7784b3df8c328debac7afc51
   stage: jar
   script:
     - ./gradlew :worker:jar
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 132b9a167de708cd7591d1e86e5e76aa6a1f4208..eccf2e382b2d63bfdf1da0a5dc04f54f3cefb096 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,7 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 ### Added
+* Submit script API integration (!177)
 ### Changed
+* PyLint is now an external dependency (!177)
 ### Fixed
 * Some analyzers were not loaded (!176)
 
diff --git a/build-env.Dockerfile b/build-env.Dockerfile
index 6fa8514a0993ed0e6916c3720e52c51ba9b9f54d..a58e771d627eb8fc4403589fb5c381c0ba5bd82f 100644
--- a/build-env.Dockerfile
+++ b/build-env.Dockerfile
@@ -12,7 +12,7 @@ RUN sh -c 'apt-get update \
             libssl-dev git python3-pip curl \
     && curl -fsSL https://deb.nodesource.com/setup_16.x | bash - \
     && apt-get install -y nodejs \
-    && pip3 install profanity-check \
+    && pip3 install profanity-check pylint==2.14.5 \
     && gem install -N github-linguist \
     && git config --global user.email 'auta-build-env+noreply@auta.ewi.tudelft.nl' \
     && git config --global user.name 'auta-build-env' \
diff --git a/build.gradle b/build.gradle
index 8be069aa8854291766703fe104d0fe764bf22d68..69670a47bb569251891f60c318f0d57d4481b279 100644
--- a/build.gradle
+++ b/build.gradle
@@ -12,7 +12,7 @@ buildscript {
 }
 
 plugins {
-    id "com.github.spotbugs-base" version "5.0.4" apply false
+    id "com.github.spotbugs-base" version "5.0.10" apply false
     id "com.github.hierynomus.license-report" version "0.15.0"
 }
 
@@ -73,7 +73,7 @@ allprojects {
     }
 
     spotbugs {
-        toolVersion = '3.1.12'
+        toolVersion = '4.7.1'
 
         showProgress = true
         effort = 'max'
diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml
index e3eac59b513ab89e5454c1f12b06534b034d24ce..d98aad57d0eee555233c83bc87fc101de8e9fe95 100644
--- a/config/checkstyle/suppressions.xml
+++ b/config/checkstyle/suppressions.xml
@@ -17,4 +17,6 @@
 
     <suppress checks="ParameterNumber" files="CPMController.java" />
 
+    <suppress checks="VisibilityModifier" files=".*Dto\.java$" />
+
 </suppressions>
diff --git a/config/spotbugs/suppressions.xml b/config/spotbugs/suppressions.xml
index 8ea457515d9e99bd7a709cf390e9fad6dbb59e08..4b41cb2f36e2f6d9ccb708eb99cc8b9337d54ea6 100644
--- a/config/spotbugs/suppressions.xml
+++ b/config/spotbugs/suppressions.xml
@@ -1,10 +1,19 @@
 <?xml version="1.0" encoding="UTF-8" ?>
 <FindBugsFilter
-        xmlns="http://findbugs.sourceforge.net/filter/3.0.0"
+        xmlns="https://github.com/spotbugs/filter/3.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-        xsi:schemaLocation="http://findbugs.sourceforge.net/filter/3.0.0 https://raw.githubusercontent.com/findbugsproject/findbugs/master/findbugs/etc/findbugsfilter.xsd">
+        xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd">
   <Match>
     <!-- Disable SpotBugs for ANTLR generated classes -->
     <Class name="~nl\.tudelft\.ewi\.auta\.checker\.grammar.*" />
   </Match>
+
+  <Match>
+    <!-- "may expose internal representation by storing an externally mutable object" is more or
+         less impossible to fix without a rewrite -->
+    <Or>
+      <Bug pattern="EI_EXPOSE_REP" />
+      <Bug pattern="EI_EXPOSE_REP2" />
+    </Or>
+  </Match>
 </FindBugsFilter>
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java b/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java
index 5a3a43109d87fd23b9c02c714817e73aebfcb300..f6aea64e62ef7ae54bc55f52df7f3d53e81b9bfa 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/Core.java
@@ -39,6 +39,8 @@ import org.springframework.http.converter.json.MappingJackson2HttpMessageConvert
 import org.springframework.http.converter.xml.MappingJackson2XmlHttpMessageConverter;
 import org.springframework.security.web.firewall.HttpFirewall;
 import org.springframework.security.web.firewall.StrictHttpFirewall;
+import org.springframework.web.multipart.MultipartResolver;
+import org.springframework.web.multipart.commons.CommonsMultipartResolver;
 import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@@ -416,4 +418,9 @@ public class Core implements WebMvcConfigurer {
     public MetricFixturesProvider metricFixturesProviderFactory(final Gson gson) {
         return new MetricFixturesProvider(gson);
     }
+
+    @Bean
+    public MultipartResolver multipartResolverFactory() {
+        return new CommonsMultipartResolver();
+    }
 }
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java b/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java
new file mode 100644
index 0000000000000000000000000000000000000000..c68325a9d15ff805ddc338dce5e42d0079612941
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/controller/SubmitAppController.java
@@ -0,0 +1,468 @@
+package nl.tudelft.ewi.auta.core.controller;
+
+import com.google.gson.Gson;
+import freemarker.template.TemplateException;
+import nl.tudelft.ewi.auta.common.model.metric.Verdict;
+import nl.tudelft.ewi.auta.core.database.EntityContainer;
+import nl.tudelft.ewi.auta.core.database.Repositories;
+import nl.tudelft.ewi.auta.core.dto.SubmitAppSubmissionDto;
+import nl.tudelft.ewi.auta.core.dto.SubmitAppFeedbackDto;
+import nl.tudelft.ewi.auta.core.jobs.JobQueue;
+import nl.tudelft.ewi.auta.core.model.FileStore;
+import nl.tudelft.ewi.auta.core.model.Job;
+import nl.tudelft.ewi.auta.core.model.Submission;
+import nl.tudelft.ewi.auta.core.model.SubmitAppMetadata;
+import nl.tudelft.ewi.auta.core.report.ReportCreatedEvent;
+import nl.tudelft.ewi.auta.core.report.SubmitAppReportGenerator;
+import nl.tudelft.ewi.auta.core.response.Response;
+import nl.tudelft.ewi.auta.core.response.exception.InvalidFileNameException;
+import nl.tudelft.ewi.auta.core.response.exception.NoFileException;
+import nl.tudelft.ewi.auta.core.response.exception.NoSuchSubmissionException;
+import nl.tudelft.ewi.auta.srf.model.AccessLevel;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.ApplicationListener;
+import org.springframework.data.util.Pair;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.net.HttpURLConnection;
+import java.net.MalformedURLException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A controller interfacing with Submit.
+ */
+@RestController
+public class SubmitAppController extends ControllerBase
+        implements ApplicationListener<ReportCreatedEvent> {
+    private static final Logger logger = LoggerFactory.getLogger(SubmitAppController.class);
+
+    /**
+     * The repositories.
+     */
+    private final Repositories repositories;
+
+    /**
+     * The file repository for uploaded files.
+     */
+    private final FileStore files;
+
+    /**
+     * The queue of job messages to be sent to the workers.
+     */
+    private final JobQueue queue;
+
+    /**
+     * The report generator.
+     */
+    private final SubmitAppReportGenerator reportGenerator;
+
+    /**
+     * The JSON serialization service.
+     */
+    private final Gson gson;
+
+    /**
+     * The access levels to use when generating feedback for the student.
+     */
+    private static final Map<AccessLevel, String> SUBMIT_ACCESS_LEVEL_MAP = Map.of(
+            AccessLevel.PUBLIC, "STUDENT",
+            AccessLevel.SUBMITTER, "STUDENT",
+            AccessLevel.EDUCATIONAL, "TA",
+            AccessLevel.INSTRUCTOR, "TEACHER"
+    );
+
+    /**
+     * Submit grade scheme ranges.
+     */
+    private static final Map<String, Pair<Integer, Integer>> SUBMIT_GRADE_SCHEME_RANGE = Map.of(
+            "PASS_FAIL", Pair.of(0, 1),
+            "DUTCH_GRADE", Pair.of(10, 100),
+            "DUTCH_GRADE_1000", Pair.of(100, 1000),
+            "SCORE_OUT_OF_10", Pair.of(0, 10),
+            "SCORE_OUT_OF_100", Pair.of(0, 100),
+            "SCORE_OUT_OF_1000", Pair.of(0, 1000),
+            "SCORE_OUT_OF_10000", Pair.of(0, 10000),
+            "UNLIMITED", Pair.of(0, Integer.MAX_VALUE)
+    );
+
+    /**
+     * Creates a new Submit integration controller.
+     *
+     * @param repositories the repositories
+     * @param files the file store
+     * @param queue the job queue
+     * @param reportGenerator the report generator
+     * @param gson the JSON serializer
+     */
+    public SubmitAppController(
+            final Repositories repositories,
+            final FileStore files,
+            final JobQueue queue,
+            final SubmitAppReportGenerator reportGenerator,
+            final Gson gson
+    ) {
+        this.repositories = repositories;
+        this.files = files;
+        this.queue = queue;
+        this.reportGenerator = reportGenerator;
+        this.gson = gson;
+    }
+
+    /**
+     * Receives a submission from Submit.
+     *
+     * This follows the
+     * <a href="https://gitlab.ewi.tudelft.nl/eip/labrador/submit/-/blob/7a0dd3e996fff2866a0e9d03807b356ce26c6002/docs/ScriptApi.md">
+     * Submit Script API</a>.
+     *
+     * @param aid the assignment's ID
+     * @param submissionDto the submission from Submit
+     *
+     * @return a 201 Created response containing an id field with the submission's ID, or an error
+     *
+     * @throws IOException if the uploaded file could not be processed
+     * @throws URISyntaxException if no valid URL could be generated for the submission
+     */
+    @PostMapping(
+            path = "/api/v1/assignment/{aid}/submission/from-submit",
+            consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
+            produces = MediaType.APPLICATION_JSON_VALUE
+    )
+    @SuppressWarnings("ParameterNumber")
+    public ResponseEntity<Response> receiveSubmission(
+            final @PathVariable String aid,
+            final @RequestParam("file") MultipartFile file,
+            final @RequestParam("submissionId") long submissionId,
+            final @RequestParam("scriptId") long scriptId,
+            final @RequestParam("key") String key,
+            final @RequestParam("gradeScheme") String gradeScheme,
+            final @RequestParam("url") String url,
+            final @ModelAttribute SubmitAppSubmissionDto submissionDto
+    ) throws IOException, URISyntaxException {
+        final var res = new Response();
+
+        // ???  DTO is not being mapped properly by @ModelAttribute...
+        submissionDto.file = file;
+        submissionDto.submissionId = submissionId;
+        submissionDto.scriptId = scriptId;
+        submissionDto.key = key;
+        submissionDto.gradeScheme = gradeScheme;
+        submissionDto.url = url;
+
+        logger.debug(
+                "Receiving submission from Submit, submission ID {}, script ID {}, filename {}",
+                submissionDto.submissionId,
+                submissionDto.scriptId,
+                submissionDto.file.getOriginalFilename()
+        );
+
+        if (submissionDto.file == null) {
+            throw new NoFileException("The submission does not contain a file");
+        }
+
+        @Nullable
+        final var originalFilename = submissionDto.file.getOriginalFilename();
+        if (originalFilename == null || originalFilename.isEmpty()) {
+            throw new InvalidFileNameException("The file has no name");
+        }
+
+        final var submissionRepository = this.repositories.getSubmissionRepository();
+        final var assignmentRepository = this.repositories.getAssignmentRepository();
+
+        final var assignment = assignmentRepository.findExisting(aid);
+
+        final var extension = this.getExtension(originalFilename);
+        final String filename = submissionDto.submissionId + extension;
+
+        final var temp = Files.createTempFile("auta-upload-", extension);
+        submissionDto.file.transferTo(temp.toFile());
+        this.files.add(filename, temp);
+
+        var submission = new Submission();
+        submission.setName("submit-submission-" + submissionDto.submissionId);
+        submission.setAssignmentId(aid);
+        submission.setContents("/" + filename);
+        submission.getPipelineLog().setSubmitted(Instant.now());
+        submission.setSubmitAppMetadata(new SubmitAppMetadata(
+                submissionDto.submissionId,
+                submissionDto.scriptId,
+                submissionDto.key,
+                submissionDto.gradeScheme,
+                submissionDto.url
+        ));
+
+        submission = submissionRepository.save(submission);
+        @Nullable
+        final var sid = submission.getId();
+        Objects.requireNonNull(sid);
+        res.put("id", sid);
+
+        this.queue.add(new Job(assignment, submission));
+
+        return ResponseEntity.created(this.generateSubmissionUri(aid, sid)).body(res);
+    }
+
+    /**
+     * Sends a finished report to Submit.
+     *
+     * @param event the event
+     */
+    @Override
+    public void onApplicationEvent(final ReportCreatedEvent event) {
+        final var sid = event.getSubmissionId();
+        final var submissionRepo = this.repositories.getSubmissionRepository();
+        final var submission = submissionRepo.findById(sid)
+                .orElseThrow(() -> new NoSuchSubmissionException("No submission with id " + sid));
+
+        @Nullable
+        final var submitMetadata = submission.getSubmitAppMetadata();
+        if (submitMetadata == null) {
+            return;
+        }
+
+        final var entityRepository = this.repositories.getEntityRepository();
+        @Nullable
+        final var entityContainer = entityRepository.findByParentIds(
+                submission.getId(), submission.getAssignmentId()
+        ).orElse(null);
+        if (entityContainer == null) {
+            return;
+        }
+
+        this.generateAndSendFeedback(entityContainer, submitMetadata);
+    }
+
+    /**
+     * Generates reports for each access level and sends them to Submit.
+     *
+     * @param entityContainer the container for the analyzed entity
+     * @param submitMetadata metadata on where and how to send the feedback
+     */
+    private void generateAndSendFeedback(
+            final EntityContainer entityContainer, final SubmitAppMetadata submitMetadata
+    ) {
+        try {
+            final var url = new URL(submitMetadata.getUrl());
+
+            if (entityContainer.hadException()) {
+                this.sendError(url, entityContainer, submitMetadata);
+                return;
+            }
+
+            SUBMIT_ACCESS_LEVEL_MAP.entrySet().stream().forEach(e -> {
+                try {
+                    final var report = this.reportGenerator.generateReport(
+                            entityContainer, Set.of(e.getKey())
+                    ).trim();
+
+                    this.sendFeedback(url, report, e.getValue(), submitMetadata, null, true);
+                } catch (final IOException | TemplateException ex) {
+                    logger.error("Could not template report", ex);
+                }
+            });
+
+            this.sendScore(url, submitMetadata, entityContainer.getVerdict());
+        } catch (final MalformedURLException ex) {
+            logger.error("Bad Submit callback URL {}", submitMetadata.getUrl(), ex);
+        }
+    }
+
+    /**
+     * Sends an internal error to Submit.
+     *
+     * @param url the URL to send the error to
+     * @param entityContainer the container of the entity that caused the error
+     * @param submitMetadata metadata to be attached to the error message
+     */
+    private void sendError(
+            final URL url,
+            final EntityContainer entityContainer,
+            final SubmitAppMetadata submitMetadata
+    ) {
+        // Send the first bit of feedback with a "true" success, because sending an error locks
+        // down the submission's feedback
+        this.sendFeedback(
+                url, "The script encountered an internal error and could not finish checking "
+                      + "your submission.",
+                "STUDENT", submitMetadata, null, true
+        );
+        this.sendFeedback(
+                url, entityContainer.getExceptionMessage(), "TA", submitMetadata, null, false
+        );
+    }
+
+    /**
+     * Sends a report to Submit.
+     *
+     * If the report is empty, this does nothing.
+     *
+     * @param url the URL to send the feedback to
+     * @param report the report to send
+     * @param visibleFor for whom the report should be visible
+     * @param submitMetadata metadata to be attached to the feedback
+     * @param score the final score, if any
+     * @param success whether the feedback is part of a successful run (true) or a script failure
+     *                (false)
+     */
+    private void sendFeedback(
+            final URL url,
+            final @Nullable String report,
+            final String visibleFor,
+            final SubmitAppMetadata submitMetadata,
+            final @Nullable Integer score,
+            final boolean success
+    ) {
+        if (report != null && report.isEmpty()) {
+            return;
+        }
+
+        try {
+            final var conn = (HttpURLConnection) url.openConnection();
+            conn.setDoOutput(true);
+            conn.setRequestMethod("POST");
+            conn.setRequestProperty("Content-Type", "application/json");
+            try (
+                    var out = conn.getOutputStream();
+                    var writer = new OutputStreamWriter(out, StandardCharsets.UTF_8)
+            ) {
+                final var body = new SubmitAppFeedbackDto();
+                body.submissionId = submitMetadata.getSubmissionId();
+                body.scriptId = submitMetadata.getScriptId();
+                body.key = submitMetadata.getKey();
+                body.visibleFor = visibleFor;
+                body.textualFeedback = report;
+                body.score = score;
+                body.success = success;
+
+                if (logger.isDebugEnabled()) {
+                    logger.debug("Sending feedback object to {}:\n{}", url,
+                            this.gson.toJson(body)
+                                    .replaceAll("\"key\":\".*?\"", "\"key\":\"<hidden>\"")
+                    );
+                }
+
+                this.gson.toJson(body, writer);
+            }
+
+            final var status = conn.getResponseCode();
+            if (status < 200 || status >= 300) {
+                this.handleError(conn);
+            }
+        } catch (final IOException ex) {
+            logger.error("Could not send report to Submit", ex);
+        }
+    }
+
+    /**
+     * Handles errors received when sending feedback.
+     *
+     * @param conn the connection the feedback was sent on
+     *
+     * @throws IOException
+     */
+    private void handleError(final HttpURLConnection conn) throws IOException {
+        final var status = conn.getResponseCode();
+
+        try (var err = conn.getErrorStream()) {
+            Charset encoding = StandardCharsets.UTF_8;
+            final var responseCharset = conn.getContentEncoding();
+
+            try {
+                encoding = Charset.forName(responseCharset);
+            } catch (final UnsupportedCharsetException ex) {
+                logger.warn("Error response uses unsupported charset {}; defaulting to {}",
+                        responseCharset, encoding.name()
+                );
+            }
+
+            logger.error("Submit returned unexpected status {} {}. Response is:\n{}",
+                    status, conn.getResponseMessage(),
+                    new String(err.readNBytes(65536), encoding)
+            );
+
+            throw new IOException("Submit returned unexpected status " + status);
+        }
+    }
+
+    /**
+     * Sends the score matching the given verdict to Submit.
+     *
+     * @param url the URL to post the score to
+     * @param submitMetadata metadata to attach to the score
+     * @param verdict the verdict mapping to the score
+     */
+    private void sendScore(
+            final URL url,
+            final SubmitAppMetadata submitMetadata,
+            final Verdict verdict
+    ) {
+        final int score;
+        final var range = SUBMIT_GRADE_SCHEME_RANGE.get(submitMetadata.getGradeScheme());
+        if (verdict == Verdict.PASS || verdict == Verdict.WARN) {
+            score = range.getSecond();
+        } else if (verdict == Verdict.FAIL) {
+            score = range.getFirst();
+        } else {
+            score = -1;
+        }
+
+        logger.info("Sending score {} to Submit for submission {} as script {}",
+                score, submitMetadata.getSubmissionId(), submitMetadata.getScriptId()
+        );
+
+        this.sendFeedback(url, null, "STUDENT", submitMetadata, score, true);
+    }
+
+    /**
+     * Generates a URI to a submission.
+     *
+     * @param aid the assignment ID
+     * @param sid the submission ID
+     *
+     * @return the URI
+     *
+     * @throws URISyntaxException if the final URI violates the URI specification
+     */
+    @Nonnull
+    private URI generateSubmissionUri(
+            final String aid, final String sid
+    ) throws URISyntaxException {
+        return new URI("/api/v1/assignment/" + aid + "/submission/" + sid);
+    }
+
+    /**
+     * Gets the extension of a filename.
+     *
+     * @param filename the filename
+     * @return the extension of the file
+     */
+    private String getExtension(final String filename) {
+        if (filename.lastIndexOf(".") != -1 && filename.lastIndexOf(".") != 0) {
+            return filename.substring(filename.lastIndexOf("."));
+        } else {
+            return "";
+        }
+    }
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppFeedbackDto.java b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppFeedbackDto.java
new file mode 100644
index 0000000000000000000000000000000000000000..e09c0684841185702e7be62999535c7a4af4f1b7
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppFeedbackDto.java
@@ -0,0 +1,53 @@
+package nl.tudelft.ewi.auta.core.dto;
+
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
+
+import javax.annotation.Nullable;
+
+/**
+ * A DTO for sending Submit feedback.
+ */
+@SuppressFBWarnings(
+        value = {"URF_UNREAD_PUBLIC_OR_PROTECTED_FIELD"},
+        justification = "false positive; values are read by GSON serialization"
+)
+public class SubmitAppFeedbackDto {
+    /**
+     * The Submit submission ID.
+     */
+    public long submissionId = -1;
+
+    /**
+     * The Submit script ID for the script that triggered this analysis.
+     */
+    public long scriptId = -1;
+
+    /**
+     * The authorization key.
+     */
+    public String key = "";
+
+    /**
+     * The feedback access level.
+     */
+    public String visibleFor = "NOBODY";
+
+    /**
+     * The feedback itself.
+     */
+    @Nullable
+    public String textualFeedback = "";
+
+    /**
+     * The associated score.
+     *
+     * If this is non-null, this is the last object than can be sent for this submission and key.
+     */
+    @Nullable
+    public Integer score = null;
+
+    /**
+     * Whether analysis succeeded (true) or encountered an error (false).
+     */
+    public boolean success = true;
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppSubmissionDto.java b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppSubmissionDto.java
new file mode 100644
index 0000000000000000000000000000000000000000..73587219e13ec50d48caee152b6ea0c13e260152
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/SubmitAppSubmissionDto.java
@@ -0,0 +1,41 @@
+package nl.tudelft.ewi.auta.core.dto;
+
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Nullable;
+
+/**
+ * A DTO describing Submit script API requests bodies.
+ */
+public class SubmitAppSubmissionDto {
+    /**
+     * The submitted file.
+     */
+    @Nullable
+    public MultipartFile file = null;
+
+    /**
+     * The Submit submission ID.
+     */
+    public long submissionId = -1;
+
+    /**
+     * The Submit script ID for the script that called AuTA.
+     */
+    public long scriptId = -1;
+
+    /**
+     * The feedback authorization key.
+     */
+    public String key = "";
+
+    /**
+     * The grading scheme to use.
+     */
+    public String gradeScheme = "";
+
+    /**
+     * The URL to post feedback to.
+     */
+    public String url = "";
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/dto/package-info.java b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/package-info.java
new file mode 100644
index 0000000000000000000000000000000000000000..fd5316a2e8509daf282b6fc58f831e99bbe27a7d
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/dto/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Data transfer objects.
+ */
+@NonnullByDefault
+package nl.tudelft.ewi.auta.core.dto;
+
+import nl.tudelft.ewi.auta.common.annotation.NonnullByDefault;
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java
index 6844e6883fd49918e03d43d9c3d886a34f7ee13c..267836c93da9ede87d2247b9b6f376bfee3abe85 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Report.java
@@ -59,52 +59,44 @@ public class Report extends HashMap<String, Object> {
      */
     @SuppressWarnings("unchecked")
     public String toPrettyString() {
-        try {
-            var staticReport = new StringBuilder();
-            var staticResults = (List<Map<String, Object>>) this.get("static");
-
-            for (var files : staticResults) {
-                StringBuilder metricString = new StringBuilder();
-                StringBuilder warningString = new StringBuilder();
-                StringBuilder failureString = new StringBuilder();
-
-                var fileName = (String) files.get("filename");
-                var fileReport = (List<Map<String, Object>>) files.get("report");
-
-                for (var metric : fileReport) {
-                    if (metric.containsKey("error")) {
-                        continue;
-                    }
-                    warningString.append(this.getWarningOrFailureString("warnings", metric));
-                    failureString.append(this.getWarningOrFailureString("failures", metric));
-                }
+        var staticReport = new StringBuilder();
+        var staticResults = (List<Map<String, Object>>) this.get("static");
 
-                //Only append metric results if there are failures or warnings
-                if (warningString.length() + failureString.length() > 0) {
-                    staticReport.append("<pre style='background-color:#fff; line-height:10px'>")
-                            .append(fileName).append('\n');
-                    if (warningString.length() > 0) {
-                        staticReport.append("   <b>warnings:</b>")
-                                .append('\n')
-                                .append(warningString);
-                    }
-                    if (failureString.length() > 0) {
-                        staticReport.append("   <b>failures:</b>")
-                                .append('\n')
-                                .append(failureString);
-                    }
-                    staticReport.append(metricString).append("</pre>");
-                }
+        for (var files : staticResults) {
+            StringBuilder metricString = new StringBuilder();
+            StringBuilder warningString = new StringBuilder();
+            StringBuilder failureString = new StringBuilder();
 
+            var fileName = (String) files.get("filename");
+            var fileReport = (List<Map<String, Object>>) files.get("report");
+
+            for (var metric : fileReport) {
+                if (metric.containsKey("error")) {
+                    continue;
+                }
+                warningString.append(this.getWarningOrFailureString("warnings", metric));
+                failureString.append(this.getWarningOrFailureString("failures", metric));
             }
-            return staticReport.toString();
-        } catch (NullPointerException ex) {
-            logger.error("NullPointer exception occurred while printing report", ex);
-            if (this.get("static") != null) {
-                return this.get("static").toString();
+
+            //Only append metric results if there are failures or warnings
+            if (warningString.length() + failureString.length() > 0) {
+                staticReport.append("<pre style='background-color:#fff; line-height:10px'>")
+                        .append(fileName).append('\n');
+                if (warningString.length() > 0) {
+                    staticReport.append("   <b>warnings:</b>")
+                            .append('\n')
+                            .append(warningString);
+                }
+                if (failureString.length() > 0) {
+                    staticReport.append("   <b>failures:</b>")
+                            .append('\n')
+                            .append(failureString);
+                }
+                staticReport.append(metricString).append("</pre>");
             }
-            return "Invalid report";
+
         }
+        return staticReport.toString();
     }
 
     /**
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java
index b3c4e5662149cab3728649dde5c5f568778ae028..ba1cb300233f6d7f5d7db4eba11160f16e54edf1 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/model/Submission.java
@@ -44,6 +44,12 @@ public class Submission {
     @Nullable
     private String cpmVerificationCode = null;
 
+    /**
+     * Submission metadata supplied by Submit.
+     */
+    @Nullable
+    private SubmitAppMetadata submitAppMetadata = null;
+
     /**
      * Whether the submission's job was canceled.
      */
@@ -90,6 +96,16 @@ public class Submission {
         this.name = other.name;
         this.contents = other.contents;
         this.cpmVerificationCode = other.cpmVerificationCode;
+        this.canceled = other.canceled;
+        if (other.submitAppMetadata != null) {
+            this.submitAppMetadata = new SubmitAppMetadata(
+                    other.submitAppMetadata.getSubmissionId(),
+                    other.submitAppMetadata.getScriptId(),
+                    other.submitAppMetadata.getKey(),
+                    other.submitAppMetadata.getGradeScheme(),
+                    other.submitAppMetadata.getUrl()
+            );
+        }
 
         this.pipelineLog = new SubmissionPipelineLog(this.pipelineLog);
     }
@@ -182,6 +198,25 @@ public class Submission {
         this.cpmVerificationCode = cpmVerificationCode;
     }
 
+    /**
+     * Returns submission metadata supplied by Submit.
+     *
+     * @return Submit metadata
+     */
+    @Nullable
+    public SubmitAppMetadata getSubmitAppMetadata() {
+        return this.submitAppMetadata;
+    }
+
+    /**
+     * Sets submission metadata supplied by Submit.
+     *
+     * @param submitAppMetadata Submit metadata
+     */
+    public void setSubmitAppMetadata(final @Nullable SubmitAppMetadata submitAppMetadata) {
+        this.submitAppMetadata = submitAppMetadata;
+    }
+
     /**
      * Returns whether the submission's job was canceled.
      *
@@ -231,6 +266,7 @@ public class Submission {
                 && Objects.equals(this.name, other.name)
                 && Objects.equals(this.contents, other.contents)
                 && Objects.equals(this.cpmVerificationCode, other.cpmVerificationCode)
+                && Objects.equals(this.submitAppMetadata, other.submitAppMetadata)
                 && Objects.equals(this.pipelineLog, other.pipelineLog)
                 && this.canceled == other.canceled;
     }
@@ -243,6 +279,7 @@ public class Submission {
                 this.name,
                 this.contents,
                 this.cpmVerificationCode,
+                this.submitAppMetadata,
                 this.pipelineLog,
                 this.canceled
         );
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/model/SubmitAppMetadata.java b/core/src/main/java/nl/tudelft/ewi/auta/core/model/SubmitAppMetadata.java
new file mode 100644
index 0000000000000000000000000000000000000000..2895f064a30ab525eb0549d3c4398c4db977caaa
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/model/SubmitAppMetadata.java
@@ -0,0 +1,184 @@
+package nl.tudelft.ewi.auta.core.model;
+
+import java.util.Objects;
+
+/**
+ * Submission metadata as supplied by Submit.
+ */
+public class SubmitAppMetadata {
+    /**
+     * Submit's submission ID.
+     */
+    private long submissionId;
+
+    /**
+     * Submit's script ID for the script that called AuTA.
+     */
+    private long scriptId;
+
+    /**
+     * The key to authenticate with when returning feedback.
+     */
+    private String key;
+
+    /**
+     * The grade scheme to use.
+     *
+     * This is one of the values listed in the "Allowed scores" table on
+     * https://gitlab.ewi.tudelft.nl/eip/labrador/submit/-/blob/8344af40/docs/ScriptApi.md
+     * For future extensibility, any value is accepted here.
+     */
+    private String gradeScheme;
+
+    /**
+     * The URL to post feedback to.
+     */
+    private String url;
+
+    /**
+     * Creates a new Submit metadata object.
+     *
+     * The members are initialized to (likely) invalid values. IDs are -1 and the key and grade
+     * scheme are empty strings.
+     */
+    public SubmitAppMetadata() {
+        this.submissionId = -1;
+        this.scriptId = -1;
+        this.key = "";
+        this.gradeScheme = "";
+        this.url = "";
+    }
+
+    /**
+     * Creates a new Submit metadata object.
+     *
+     * @param submissionId the Submit submission ID
+     * @param scriptId the ID of the script calling AuTA
+     * @param key the key to authenticate with when uploading feedback
+     * @param gradeScheme the grade scheme to use
+     * @param url the URL to send feedback to
+     */
+    public SubmitAppMetadata(
+            final long submissionId,
+            final long scriptId,
+            final String key,
+            final String gradeScheme,
+            final String url
+    ) {
+        this.submissionId = submissionId;
+        this.scriptId = scriptId;
+        this.key = key;
+        this.gradeScheme = gradeScheme;
+        this.url = url;
+    }
+
+    /**
+     * Returns the Submit submission ID.
+     *
+     * @return the submission ID
+     */
+    public long getSubmissionId() {
+        return this.submissionId;
+    }
+
+    /**
+     * Sets the Submit submission ID.
+     *
+     * @param submissionId the submission ID
+     */
+    public void setSubmissionId(final long submissionId) {
+        this.submissionId = submissionId;
+    }
+
+    /**
+     * Returns the ID of the script that called AuTA.
+     *
+     * @return the script ID
+     */
+    public long getScriptId() {
+        return this.scriptId;
+    }
+
+    /**
+     * Sets the ID of the script that called AuTA.
+     *
+     * @param scriptId the script ID
+     */
+    public void setScriptId(final long scriptId) {
+        this.scriptId = scriptId;
+    }
+
+    /**
+     * Returns the key to authenticate with when returning feedback.
+     *
+     * @return the authentication key
+     */
+    public String getKey() {
+        return this.key;
+    }
+
+    /**
+     * Sets the key to authenticate with when returning feedback.
+     *
+     * @param key the authentication key
+     */
+    public void setKey(final String key) {
+        this.key = key;
+    }
+
+    /**
+     * Returns the grade scheme to use.
+     *
+     * @return the grade scheme
+     */
+    public String getGradeScheme() {
+        return this.gradeScheme;
+    }
+
+    /**
+     * Sets the grade scheme to use.
+     *
+     * @param gradeScheme the grade scheme
+     */
+    public void setGradeScheme(final String gradeScheme) {
+        this.gradeScheme = gradeScheme;
+    }
+
+    /**
+     * Returns the URL to send feedback to.
+     *
+     * @return the URL
+     */
+    public String getUrl() {
+        return this.url;
+    }
+
+    /**
+     * Sets the URL to send feedback to.
+     *
+     * @param url the URL
+     */
+    public void setUrl(final String url) {
+        this.url = url;
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (!(obj instanceof SubmitAppMetadata)) {
+            return false;
+        }
+
+        final var other = (SubmitAppMetadata) obj;
+
+        return this.submissionId == other.submissionId
+                && this.scriptId == other.scriptId
+                && Objects.equals(this.key, other.key)
+                && Objects.equals(this.gradeScheme, other.gradeScheme)
+                && Objects.equals(this.url, other.url);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(this.submissionId, this.scriptId, this.key, this.gradeScheme, this.url);
+    }
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java b/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java
index bb3297aebc704d478166cbbe012a98317ae5d829..b859d52ed023dd16ccc1cc6776345d3e6f708efe 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/report/ReportAuthorizationFilter.java
@@ -105,7 +105,8 @@ public class ReportAuthorizationFilter {
         final var filteredNotes = new NoteCollection();
         filteredNotes.setNotes(report.getNotes().getNotes().stream()
                 .filter(n ->
-                        n.getAccessLevel() == null || accessLevels.contains(n.getAccessLevel())
+                        (n.getAccessLevel() == null && accessLevels.contains(AccessLevel.PUBLIC))
+                        || (n.getAccessLevel() != null && accessLevels.contains(n.getAccessLevel()))
                 )
                 .collect(Collectors.toUnmodifiableList())
         );
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/report/SubmitAppReportGenerator.java b/core/src/main/java/nl/tudelft/ewi/auta/core/report/SubmitAppReportGenerator.java
new file mode 100644
index 0000000000000000000000000000000000000000..960c4f8c9990fba61651e22b69fe4e689ba3212a
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/report/SubmitAppReportGenerator.java
@@ -0,0 +1,38 @@
+package nl.tudelft.ewi.auta.core.report;
+
+import nl.tudelft.ewi.auta.core.authentication.UserProvider;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.view.freemarker.FreeMarkerConfigurer;
+
+/**
+ * Generates reports for consumption by Submit.
+ *
+ * Follows a Markdown-like style.
+ */
+@Service
+public class SubmitAppReportGenerator extends GenericReportGenerator {
+    /**
+     * Creates a new Submit report generator.
+     *
+     * @param configurer the freemarker configurer
+     * @param reportAuthorizationFilter the report authorization filter
+     * @param userProvider the user provider to determine the current authorization level
+     */
+    public SubmitAppReportGenerator(
+            final FreeMarkerConfigurer configurer,
+            final ReportAuthorizationFilter reportAuthorizationFilter,
+            final UserProvider userProvider
+    ) {
+        super(configurer, reportAuthorizationFilter, userProvider);
+    }
+
+    /**
+     * Returns the template name.
+     *
+     * @return the template name
+     */
+    @Override
+    public String getTemplateName() {
+        return "submit/submit-report.ftlh";
+    }
+}
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java b/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java
index 1336f69e0909a89f3e0df8a03a001bdcb6870423..6edb4cddbf6aa4a388419beede11aa2b440f66a7 100644
--- a/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/response/ErrorCode.java
@@ -178,7 +178,12 @@ public enum ErrorCode {
     /**
      * The submitted file has an invalid name.
      */
-    INVALID_FILE_NAME(HttpStatus.UNPROCESSABLE_ENTITY);
+    INVALID_FILE_NAME(HttpStatus.UNPROCESSABLE_ENTITY),
+
+    /**
+     * The submission contained no file.
+     */
+    NO_FILE(HttpStatus.BAD_REQUEST);
 
     /**
      * The associated HTTP status code.
diff --git a/core/src/main/java/nl/tudelft/ewi/auta/core/response/exception/NoFileException.java b/core/src/main/java/nl/tudelft/ewi/auta/core/response/exception/NoFileException.java
new file mode 100644
index 0000000000000000000000000000000000000000..53eaf1e8e4af7401a3c608f09742ec2bb729c49e
--- /dev/null
+++ b/core/src/main/java/nl/tudelft/ewi/auta/core/response/exception/NoFileException.java
@@ -0,0 +1,45 @@
+package nl.tudelft.ewi.auta.core.response.exception;
+
+import nl.tudelft.ewi.auta.core.response.ErrorCode;
+
+/**
+ * An exception indicating that the submission did not contain a file.
+ */
+public class NoFileException extends ApiException {
+    /**
+     * Creates a no file exception.
+     */
+    public NoFileException() {
+    }
+
+    /**
+     * Creates a no file exception with a message.
+     * @param message the message
+     */
+    public NoFileException(final String message) {
+        super(message);
+    }
+
+    /**
+     * Creates a no file exception with a cause.
+     * @param cause the cause
+     */
+    public NoFileException(final Throwable cause) {
+        super(cause);
+    }
+
+    /**
+     * Creates a no file exception with a cause and a message.
+     *
+     * @param message the message
+     * @param cause the cause
+     */
+    public NoFileException(final String message, final Throwable cause) {
+        super(message, cause);
+    }
+
+    @Override
+    public ErrorCode getErrorCode() {
+        return ErrorCode.NO_FILE;
+    }
+}
diff --git a/core/src/main/resources/nl/tudelft/ewi/auta/core/ui/submit/submit-report.ftlh b/core/src/main/resources/nl/tudelft/ewi/auta/core/ui/submit/submit-report.ftlh
new file mode 100644
index 0000000000000000000000000000000000000000..695366d89dbb3c1bc05ff4cb5e96195341037b33
--- /dev/null
+++ b/core/src/main/resources/nl/tudelft/ewi/auta/core/ui/submit/submit-report.ftlh
@@ -0,0 +1,56 @@
+<#-- @ftlvariable name="error" type="String" -->
+<#-- @ftlvariable name="tips" type="java.util.Map<String, java.util.List>" -->
+<#-- @ftlvariable name="warnings" type="java.util.Map<String, java.util.List>" -->
+<#-- @ftlvariable name="failures" type="java.util.Map<String, java.util.List>" -->
+<#if error??>
+## A server-side error occurred. Please show this message to your TA
+${error}
+<#else>
+<#list tips as tip, entityNames>
+## Tip
+${tip}
+
+This applies to:
+<#list entityNames as entityName>
+* ${entityName}
+</#list>
+</#list>
+
+<#if failures?has_content>
+## Failures
+<#list failures as entityName, failureMessages>
+<#if failureMessages?has_content>
+  ### ${entityName}
+<#list failureMessages as msg>
+    ${msg}
+</#list>
+</#if>
+</#list>
+</#if>
+
+<#if warnings?has_content>
+## Warnings
+<#list warnings as entityName, warningMessages>
+<#if warningMessages?has_content>
+  ### ${entityName}
+<#list warningMessages as msg>
+    ${msg}
+</#list>
+</#if>
+</#list>
+</#if>
+
+<#if info?has_content>
+## Info
+<#list info as entityName, infoMessage>
+<#if infoMessage?has_content>
+  ### ${entityName}
+<#list infoMessage as msg>
+<#noautoesc>
+    #### ${msg}
+</#noautoesc>
+</#list>
+</#if>
+</#list>
+</#if>
+</#if>
diff --git a/core/src/test/java/nl/tudelft/ewi/auta/core/controller/SubmitAppControllerTest.java b/core/src/test/java/nl/tudelft/ewi/auta/core/controller/SubmitAppControllerTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..03c2dcd7d4726a96024fc6f1fe79ef397d89d867
--- /dev/null
+++ b/core/src/test/java/nl/tudelft/ewi/auta/core/controller/SubmitAppControllerTest.java
@@ -0,0 +1,251 @@
+package nl.tudelft.ewi.auta.core.controller;
+
+import com.google.gson.Gson;
+import nl.tudelft.ewi.auta.common.model.entity.ProjectEntity;
+import nl.tudelft.ewi.auta.core.database.AssignmentRepository;
+import nl.tudelft.ewi.auta.core.database.EntityContainer;
+import nl.tudelft.ewi.auta.core.database.EntityRepository;
+import nl.tudelft.ewi.auta.core.database.Repositories;
+import nl.tudelft.ewi.auta.core.database.RepositoriesTestHelper;
+import nl.tudelft.ewi.auta.core.database.SubmissionRepository;
+import nl.tudelft.ewi.auta.core.jobs.JobQueue;
+import nl.tudelft.ewi.auta.core.model.Assignment;
+import nl.tudelft.ewi.auta.core.model.FileStore;
+import nl.tudelft.ewi.auta.core.model.Job;
+import nl.tudelft.ewi.auta.core.model.Submission;
+import nl.tudelft.ewi.auta.core.report.ReportCreatedEvent;
+import nl.tudelft.ewi.auta.core.report.SubmitAppReportGenerator;
+import org.hamcrest.BaseMatcher;
+import org.hamcrest.Description;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.stubbing.Answer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import javax.annotation.Nullable;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.BrokenBarrierException;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import static org.junit.jupiter.api.Assertions.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+public class SubmitAppControllerTest {
+    private static final Logger logger = LoggerFactory.getLogger(SubmitAppControllerTest.class);
+
+    private static final String ASSIGNMENT_NAME = "lF0eNrA6VoFf";
+    private static final String SUBMISSION_NAME = "S39ICf6UfnyGAot0a";
+    private static final String ASSIGNMENT_ID = "0z6KG0xPG46CBA";
+    private static final String SUBMISSION_ID = "MBa9havWR6uRCtkPH";
+    private static final String RESULTS_CONTAINER_ID = "UKvuYgFqTksO";
+    private static final String FILENAME = "YDZ4b2Mw2T5pVxds";
+    private static final String SUBMIT_REPORT = "Xq4n0W90TpTm7GUF9qzvwsAo";
+
+    private MockMvc mvc;
+
+    @Mock
+    private FileStore fileStore;
+
+    @Mock
+    private JobQueue submissionQueue;
+
+    private Repositories repositories;
+
+    @Mock
+    private SubmitAppReportGenerator reportGenerator;
+
+    @Nullable
+    private Submission submission;
+
+    private Assignment assignment;
+
+    private SubmitAppController controller;
+
+    private CyclicBarrier asyncReportBarrier = new CyclicBarrier(2);
+
+    private ExecutorService asyncReportExecutor = Executors.newSingleThreadExecutor();
+
+    @BeforeEach
+    public void before() {
+        MockitoAnnotations.initMocks(this);
+
+        final var gson = new Gson();
+
+        this.repositories = new Repositories(
+                mock(EntityRepository.class),
+                mock(SubmissionRepository.class),
+                mock(AssignmentRepository.class),
+                RepositoriesTestHelper.getMockIdentityRepository(),
+                RepositoriesTestHelper.getMockCourseRepository()
+        );
+
+        this.assignment = new Assignment();
+        this.assignment.setId(ASSIGNMENT_ID);
+        this.assignment.setName(ASSIGNMENT_NAME);
+        this.assignment.setAllowedLanguages(Set.of("COBOL", "MUMPS", "ALGOL-68"));
+
+        final var assignmentRepo = this.repositories.getAssignmentRepository();
+        when(assignmentRepo.findById(eq(ASSIGNMENT_ID))).thenReturn(Optional.of(this.assignment));
+        doCallRealMethod().when(assignmentRepo).findExisting(any());
+
+        this.submission = null;
+
+        final var submissionRepo = this.repositories.getSubmissionRepository();
+
+        when(submissionRepo.save(any(Submission.class)))
+                .thenAnswer((Answer<Submission>) invocation -> {
+                    final Submission newSubmission = invocation.getArgument(0);
+                    this.submission = newSubmission;
+                    newSubmission.setId(SUBMISSION_ID);
+                    return newSubmission;
+                });
+
+        when(submissionRepo.findByAssignmentIdEqualsAndIdEquals(
+                eq(ASSIGNMENT_ID), eq(SUBMISSION_ID))
+        )
+                .then(invocation -> {
+                    if (this.submission != null
+                            && Objects.equals(this.submission.getId(), SUBMISSION_ID)
+                            && Objects.equals(this.submission.getAssignmentId(), ASSIGNMENT_ID)) {
+                        return Optional.of(this.submission);
+                    }
+
+                    return Optional.empty();
+                });
+
+        when(submissionRepo.findById(eq(SUBMISSION_ID)))
+                .then(invocation -> {
+                    if (this.submission != null
+                            && Objects.equals(this.submission.getId(), SUBMISSION_ID)) {
+                        return Optional.of(this.submission);
+                    }
+
+                    return Optional.empty();
+                });
+
+        doCallRealMethod().when(submissionRepo).findExisting(any());
+        doCallRealMethod().when(submissionRepo).findExisting(any(), any());
+
+        when(this.submissionQueue.add(any())).then(invocation -> {
+            this.asyncReportExecutor.submit(() -> {
+                try {
+                    final Job job = invocation.getArgument(0);
+                    submissionRepo.findExisting(
+                            job.getAssignment().getId(), job.getSubmission().getId()
+                    );
+
+                    var results = new ProjectEntity();
+                    var resultsContainer = new EntityContainer(
+                            results, SUBMISSION_ID, false, null, ASSIGNMENT_ID
+                    );
+                    resultsContainer.setId(RESULTS_CONTAINER_ID);
+
+                    try {
+                        when(this.reportGenerator.generateReport(eq(resultsContainer), any()))
+                                .thenReturn(SUBMIT_REPORT);
+                    } catch (final RuntimeException ex) {
+                        throw ex;
+                    } catch (final Exception ex) {
+                        throw new RuntimeException(ex);
+                    }
+
+                    Mockito.when(this.repositories.getEntityRepository()
+                                    .findByParentIds(eq(SUBMISSION_ID), eq(ASSIGNMENT_ID)))
+                            .thenReturn(Optional.of(resultsContainer));
+
+                    this.controller.onApplicationEvent(
+                            new ReportCreatedEvent(this.submission, SUBMISSION_ID)
+                    );
+                } catch (final Exception ex) {
+                    logger.error("Event handler failed", ex);
+                    fail();
+                } finally {
+                    try {
+                        this.asyncReportBarrier.await();
+                    } catch (final InterruptedException | BrokenBarrierException ex) {
+                        logger.warn("Could not signal barrier", ex);
+                    }
+                }
+            });
+
+            return true;
+        });
+
+        this.controller = new SubmitAppController(
+                this.repositories,
+                this.fileStore,
+                this.submissionQueue,
+                this.reportGenerator,
+                gson
+        );
+
+        this.mvc = MockMvcBuilders.standaloneSetup(this.controller)
+                .setControllerAdvice(new RestExceptionHandler())
+                .build();
+    }
+
+    @Test
+    public void testSubmit() throws Exception {
+        var mockMultipartFile = new MockMultipartFile("file", "submission.zip",
+                "application/zip", "6YxR0U6Y3wn5hYR".getBytes(StandardCharsets.UTF_8));
+
+        final var submitSubmissionId = 293;
+        final var submitScriptId = 410;
+        final var submitKey = "yAYM5roNXqT8DkFfC";
+        final var gradeScheme = "PASS_FAIL";
+        final var url = "https://example.org/";
+
+        this.mvc.perform(MockMvcRequestBuilders.multipart(
+                "/api/v1/assignment/" + ASSIGNMENT_ID + "/submission/from-submit")
+                .file(mockMultipartFile)
+                .param("submissionId", String.valueOf(submitSubmissionId))
+                .param("scriptId", String.valueOf(submitScriptId))
+                .param("key", submitKey)
+                .param("gradeScheme", gradeScheme)
+                .param("url", url)
+        )
+                .andExpect(status().isCreated())
+                .andExpect(header().string("Location",
+                        "/api/v1/assignment/" + ASSIGNMENT_ID + "/submission/" + SUBMISSION_ID
+                ))
+                .andExpect(content().string(new BaseMatcher<>() {
+                    @Override
+                    public boolean matches(final Object item) {
+                        final var obj = new Gson().fromJson((String) item, Map.class);
+                        return Objects.equals(obj.get("id"), SUBMISSION_ID);
+                    }
+
+                    @Override
+                    public void describeTo(final Description description) {
+                        description.appendText("correct submission ID in JSON object");
+                    }
+                }));
+
+        this.asyncReportBarrier.await();
+
+        verify(this.reportGenerator, atLeastOnce()).generateReport(any(), any());
+    }
+}
diff --git a/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java b/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java
index 918da1585ca338fc03223f75a7b236eee4190a18..cd088c8dda17680197e5aa8f3ad44c48249cbf89 100644
--- a/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java
+++ b/core/src/test/java/nl/tudelft/ewi/auta/core/database/RepositoriesTestHelper.java
@@ -14,6 +14,7 @@ import java.util.Optional;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doCallRealMethod;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.withSettings;
@@ -135,6 +136,7 @@ public final class RepositoriesTestHelper {
 
         doReturn(Optional.empty()).when(m).findById(any());
         doReturn(Optional.empty()).when(m).findByAssignmentIdEqualsAndIdEquals(any(), any());
+        doCallRealMethod().when(m).findExisting(any(), any());
 
         return m;
     }
@@ -164,6 +166,7 @@ public final class RepositoriesTestHelper {
         }).when(m).save(any());
 
         doReturn(Optional.empty()).when(m).findById(any());
+        doCallRealMethod().when(m).findExisting(any());
 
         return m;
     }
diff --git a/slim-worker.Dockerfile b/slim-worker.Dockerfile
index 2fcc3d4ca9f5fa595a57402a863c0982cae16f30..bfaa17948a31f1ee671851b8d8bfdab93fb471b6 100644
--- a/slim-worker.Dockerfile
+++ b/slim-worker.Dockerfile
@@ -10,7 +10,7 @@ RUN sh -c 'groupadd -g 215 auta \
     && mkdir -p /usr/share/man/man1 \
     && apt install -y openjdk-11-jre-headless python3 ruby ruby-dev build-essential libicu67 \
             libicu-dev zlib1g zlib1g-dev cmake pkg-config libssl1.1 libssl-dev git python3-pip \
-    && pip3 install profanity-check \
+    && pip3 install profanity-check pylint==2.14.5 \
     && gem install -N github-linguist \
     && git config --global user.email 'auta-build-env+noreply@auta.ewi.tudelft.nl' \
     && git config --global user.name 'auta-build-env' \
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
index af4875bc0bc27a9f08d1e9e5cc2cc88922d6a39c..12ea5c735297efbaa696e803fbe614ebc5a0956c 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
@@ -10,6 +10,7 @@ import java.util.Set;
 import java.util.Stack;
 import java.util.regex.Pattern;
 
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
 import nl.tudelft.ewi.auta.common.annotation.Unmodifiable;
 import org.antlr.v4.runtime.CommonTokenStream;
 import org.antlr.v4.runtime.misc.Interval;
@@ -593,6 +594,7 @@ public class AttAsmReader {
          *         is a target for an unconditional jump or a return instruction
          */
         @Contract(pure = true)
+        @SuppressFBWarnings(value = {"ES_COMPARING_STRINGS_WITH_EQ"}, justification = "see comment")
         public boolean isConditional() {
             // noinspection StringEquality - identity is ensured by constructor
             return this.falsyTarget != this.truthyTarget;
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java b/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java
index c916c1530dfa1c5d58b2a4c3f38132f6d73797a4..f187578593dffb0c7e94344e8388267e74043c44 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/checker/python/PyLint.java
@@ -12,16 +12,12 @@ import nl.tudelft.ewi.auta.common.model.metric.MetricName;
 import nl.tudelft.ewi.auta.common.model.metric.PyLintResultMetric;
 import nl.tudelft.ewi.auta.worker.Job;
 import nl.tudelft.ewi.auta.worker.config.WorkerSettings;
-import nl.tudelft.ewi.auta.worker.tool.python.Python;
 import nl.tudelft.ewi.auta.worker.tool.python.PythonProcessException;
-import nl.tudelft.ewi.auta.worker.files.Unpacker;
-import org.apache.commons.compress.utils.IOUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.IOException;
 import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -34,50 +30,22 @@ public class PyLint extends JobAnalyzer {
 
     private static final Logger logger = LoggerFactory.getLogger(PyLint.class);
 
-    /**
-     * The path to the radon files.
-     */
-    private final Path pyLintPath;
-
     /**
      * The worker settings.
      */
     private final WorkerSettings settings;
 
-    /**
-     * The Python instance PyLint will run in.
-     */
-    private final Python python;
-
     /**
      * Constructor for the Radon analyzer.
      *
      * @param workerSettings the worker settings
-     * @param unzipper the job unzipper
-     * @param python the python instance to run PyLint in
      *
      * @throws IOException if a file is not found
      */
     public PyLint(
-            final WorkerSettings workerSettings,
-            final Unpacker unzipper,
-            final Python python
-    ) throws IOException {
+            final WorkerSettings workerSettings
+    ) {
         this.settings = workerSettings;
-        this.python = python;
-        final var path = Files.createTempDirectory(this.settings.getTemp(), "pylint");
-        final var zipPath = Files.createTempFile(this.settings.getTemp(), "stream", ".zip");
-
-        try (var in = PyLint.class.getResourceAsStream(
-                "/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip");
-             var out = Files.newOutputStream(zipPath)) {
-            IOUtils.copy(in, out);
-        }
-
-        // Unzip PyLint for usage
-        unzipper.unpack(zipPath, path);
-        this.pyLintPath = path;
-        Files.delete(zipPath);
     }
 
 
@@ -100,11 +68,11 @@ public class PyLint extends JobAnalyzer {
         final var fileNames = this.getFileNames(victim);
 
         // For every module
-        for (var i = 0; i < fileNames.size(); i++) {
+        for (final var fileName : fileNames) {
             // Prepare the command to run PyLint as tool in a separate process
-            final var process = this.python.prepare(
-                    this.pyLintPath, "pylint", "--output-format=json", fileNames.get(i)
-            );
+            final var process =
+                    new ProcessBuilder("pylint", "--output-format=json", fileName);
+            process.redirectError(ProcessBuilder.Redirect.INHERIT);
 
             // Set the right environment variables and the working directory
             final var tempOutputFile = Files.createTempFile(
@@ -121,7 +89,7 @@ public class PyLint extends JobAnalyzer {
                 logger.warn("The Python process for PyLint timed out during execution");
                 throw new PythonProcessException(
                         "The Python process for PyLint timed out during execution, "
-                         + "the module that was provided might have been to large"
+                                + "the module that was provided might have been to large"
                 );
             }
 
@@ -139,12 +107,12 @@ public class PyLint extends JobAnalyzer {
                 }
             }
 
-            var moduleName = fileNames.get(i).replace("\\", "/");
+            var moduleName = fileName.replace("\\", "/");
             moduleName = moduleName.substring(moduleName.lastIndexOf("/") + 1);
 
             // Create the module entity
             final var moduleEntity = new Entity(victim.getProject(), moduleName,
-                                                false, EntityLevel.MODULE);
+                    false, EntityLevel.MODULE);
             final var moduleEntityWithMetrics = this.createModuleEntityStructure(
                     moduleEntity, pyLintResults
             );
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java b/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java
index 7a7da1be62ab656dd248d3ec5e6eb3472280e6cd..e233c369d62d7d604b2b76081345fa25d360154a 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/worker/tool/python/Python.java
@@ -4,6 +4,7 @@ import org.jetbrains.annotations.Contract;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import javax.annotation.Nullable;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -33,27 +34,35 @@ public class Python {
     /**
      * Prepares a python process for executing.
      *
-     * @param toolDir the directory the tool is installed in
-     * @param workingDir the working directory for the tool
+     * @param toolDir the directory the tool is installed in or null to use the global site packages
+     * @param workingDir the working directory for the tool or null to inherit the current WD
      * @param cmd any command line arguments (excluding the executable)
      *
      * @return a process builder ready to execute the command
      */
-    public ProcessBuilder prepare(final Path toolDir, final Path workingDir, final String... cmd) {
+    public ProcessBuilder prepare(
+            final @Nullable Path toolDir,
+            final @Nullable Path workingDir,
+            final String... cmd
+    ) {
         final var fcmd = new ArrayList<String>();
         fcmd.add(this.info.getPath().toAbsolutePath().toString());
         fcmd.addAll(Arrays.asList(cmd));
 
         final var process = new ProcessBuilder(fcmd);
-        process.environment().put("PYTHONPATH", toolDir.toAbsolutePath().toString());
-        process.directory(workingDir.toFile());
+        if (toolDir != null) {
+            process.environment().put("PYTHONPATH", toolDir.toAbsolutePath().toString());
+        }
+        if (workingDir != null) {
+            process.directory(workingDir.toFile());
+        }
         process.redirectError(ProcessBuilder.Redirect.INHERIT);
 
         if (logger.isTraceEnabled()) {
             logger.trace("Starting `{}` as tool in {} working in or on {}",
                     String.join(" ", fcmd),
-                    toolDir.toAbsolutePath(),
-                    workingDir.toAbsolutePath()
+                    toolDir,
+                    workingDir
             );
         }
 
@@ -65,20 +74,20 @@ public class Python {
      *
      * The working directory is set to the tool directory.
      *
-     * @param toolDir the directory the tool is installed in
+     * @param toolDir the directory the tool is installed in or null to use the global site-packages
      * @param cmd any command line arguments (excluding the executable)
      *
      * @return a process builder ready to execute the command
      */
-    public ProcessBuilder prepare(final Path toolDir, final String... cmd) {
+    public ProcessBuilder prepare(final @Nullable Path toolDir, final String... cmd) {
         return this.prepare(toolDir, toolDir, cmd);
     }
 
     /**
      * Executes a python process.
      *
-     * @param toolDir the directory the tool is installed in
-     * @param workingDir the working directory for the tool
+     * @param toolDir the directory the tool is installed in or null to use the global site-packages
+     * @param workingDir the working directory for the tool or null to inherit the current WD
      * @param cmd any command line arguments (excluding the executable)
      *
      * @return the running executable
@@ -86,7 +95,7 @@ public class Python {
      * @throws IOException if an I/O error occurs while communicating with the process
      */
     public Process run(
-            final Path toolDir, final Path workingDir, final String... cmd
+            final @Nullable Path toolDir, final @Nullable Path workingDir, final String... cmd
     ) throws IOException {
         return this.prepare(toolDir, workingDir, cmd).start();
     }
@@ -96,14 +105,14 @@ public class Python {
      *
      * The working directory is set to the tool directory.
      *
-     * @param toolDir the directory the tool is installed in
+     * @param toolDir the directory the tool is installed in or null to use the global site-packages
      * @param cmd any command line arguments (excluding the executable)
      *
      * @return the running executable
      *
      * @throws IOException if an I/O error occurs while communicating with the process
      */
-    public Process run(final Path toolDir, final String... cmd) throws IOException {
+    public Process run(final @Nullable Path toolDir, final String... cmd) throws IOException {
         return this.run(toolDir, toolDir, cmd);
     }
 }
diff --git a/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip b/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip
index 99394b9679362512aec2fd5de436a027b6bfdf52..8a6a8cbdf9f88d5d536ca841735e60da4228e71e 100644
Binary files a/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip and b/worker/src/main/resources/nl/tudelft/ewi/auta/worker/checker/python/pylint.zip differ
diff --git a/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java b/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java
index 561bccbe6cb759fe5153ccd92a88f7ece2f663cd..18571fcdb84300b113009c3a38a5282aa3ee7a33 100644
--- a/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java
+++ b/worker/src/test/java/nl/tudelft/ewi/auta/checker/python/PyLintTest.java
@@ -10,8 +10,6 @@ import nl.tudelft.ewi.auta.worker.config.WorkerSettings;
 import nl.tudelft.ewi.auta.worker.files.UnicodePathEncoder;
 import nl.tudelft.ewi.auta.worker.files.Unpacker;
 import nl.tudelft.ewi.auta.worker.jobconfig.JobConfig;
-import nl.tudelft.ewi.auta.worker.tool.python.Python;
-import nl.tudelft.ewi.auta.worker.tool.python.PythonDetector;
 import org.apache.commons.compress.utils.IOUtils;
 import org.junit.jupiter.api.Test;
 
@@ -36,8 +34,6 @@ public class PyLintTest {
         final var tempDir = Paths.get(System.getProperty("java.io.tmpdir"));
         final var zipPath = Files.createTempFile(tempDir, "pyFilesForTesting", ".zip");
 
-        final var python = new Python(new PythonDetector().findPython());
-
         try (var in = PyLintTest.class
                 .getResourceAsStream("/lizard.zip");
              var out = Files.newOutputStream(zipPath)) {
@@ -64,8 +60,7 @@ public class PyLintTest {
                     "name", Collections.singletonList("Otto"),
                     "api-token", List.of("TOKEN!!!")));
 
-            final var pyLint = new PyLint(settings,
-                    new Unpacker(settings, new UnicodePathEncoder()), python);
+            final var pyLint = new PyLint(settings);
             pyLint.analyze(job, new HashMap<>());
             assertThat(job.getProject().getAllChildren()).hasSizeGreaterThan(100);
         }
@@ -101,9 +96,7 @@ public class PyLintTest {
                 "name", Collections.singletonList("Otto"),
                 "api-token", List.of("TOKEN!!!")));
 
-        final var python = new Python(new PythonDetector().findPython());
-        final var pyLint = new PyLint(settings, new Unpacker(settings, new UnicodePathEncoder()),
-                python);
+        final var pyLint = new PyLint(settings);
 
         pyLint.analyze(job, Collections.emptyMap());
         assertThat(job.getProject().getLevel()).isEqualTo(EntityLevel.PROJECT);