Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -147,14 +147,20 @@ private fun findModulePackage(project: Project): String? {
private fun verifyClasses(project: Project) {
val pkg = findModulePackage(project) ?: return
val expectedDir = pkg.replace('.', '/')
// Packages the module explicitly opted out for, see `VERIFY_CLASSES_ALLOWED_PACKAGES`.
val allowedDirs = (project.findProperty(VERIFY_CLASSES_ALLOWED_PACKAGES) as? String)
.orEmpty()
.split(',')
.map { it.trim().replace('.', '/') }
.filter { it.isNotEmpty() }

var hasErrors = false
for (classesDir in project.sourceSets.main.get().output.classesDirs) {
val basePath = classesDir.toPath()
for (file in classesDir.walk()) {
if (file.isFile && file.extension == "class") {
val relativePath = basePath.relativize(file.toPath())
if (!relativePath.startsWith(expectedDir)) {
if (!relativePath.startsWith(expectedDir) && allowedDirs.none { relativePath.startsWith(it) }) {
logger.error("Wrong package of `${relativePath.joinToString(".").removeSuffix(".class")}` class. Expected `$pkg`")
hasErrors = true
}
Expand Down
7 changes: 7 additions & 0 deletions buildSrc/src/main/kotlin/intellijUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import kotlin.reflect.KProperty

const val VERIFY_CLASSES_TASK_NAME = "verifyClasses"

/**
* Extra property a module sets to let [VERIFY_CLASSES_TASK_NAME] accept classes outside its own package.
* Comma-separated package prefixes. Only for shims that must sit in a platform package to reach its
* package-private or `internal` API -- there is no other way to call it.
*/
const val VERIFY_CLASSES_ALLOWED_PACKAGES = "verifyClassesAllowedPackages"

private const val IDE_IDEA = "idea"
private const val IDE_CLION = "clion"
private const val IDE_PYCHARM = "pycharm"
Expand Down
8 changes: 4 additions & 4 deletions gradle-252.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ customUntilBuild=252.*
# Existent IDE versions can be found in the following repos:
# https://www.jetbrains.com/intellij-repository/releases/
# https://www.jetbrains.com/intellij-repository/snapshots/
ideaVersion=IU-2025.2.4
clionVersion=CL-2025.2.4
pycharmVersion=PC-2025.2.4
riderVersion=RD-2025.2.4
ideaVersion=IU-2025.2.6.3
clionVersion=CL-2025.2.6.2
pycharmVersion=PC-2025.2.6.2
riderVersion=RD-2025.2.6.1
8 changes: 4 additions & 4 deletions gradle-253.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ customUntilBuild=253.*
# Existent IDE versions can be found in the following repos:
# https://www.jetbrains.com/intellij-repository/releases/
# https://www.jetbrains.com/intellij-repository/snapshots/
ideaVersion=IU-2025.3
clionVersion=CL-2025.3
pycharmVersion=PC-2025.3
riderVersion=RD-2025.3
ideaVersion=IU-2025.3.6.1
clionVersion=CL-2025.3.6.1
pycharmVersion=PC-2025.3.6.1
riderVersion=RD-2025.3.5
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# supported values: 252, 253, 261, 262
environmentName=262

pluginVersion=2026.18
pluginVersion=2026.20

# type of IDE (IDEA, CLion, etc.) used to build/test running
# for more details see `Different IDEs` section in `PlatformVersions.md`
Expand Down
26 changes: 13 additions & 13 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,39 +1,39 @@
[versions]
educational-ml-library = "1.0.65"
jackson = "2.21.2"
kotlin = "2.3.21"
okhttp = "5.3.2"
jackson = "2.22.2"
kotlin = "2.4.10"
okhttp = "5.5.0"
retrofit = "3.0.0"

[libraries]
annotations = { group = "org.jetbrains", name = "annotations", version = "23.0.0" }
clikt-core = { module = "com.github.ajalt.clikt:clikt-core", version = "5.0.1" }
annotations = { group = "org.jetbrains", name = "annotations", version = "26.1.0" }
clikt-core = { module = "com.github.ajalt.clikt:clikt-core", version = "5.1.0" }
converter-jackson = { group = "com.squareup.retrofit2", name = "converter-jackson", version.ref = "retrofit" }
educational-ml-library-core = { group = "com.jetbrains.educational.ml", name = "educational-ml-library-core", version.ref = "educational-ml-library" }
educational-ml-library-debugger = { group = "com.jetbrains.educational.ml", name = "educational-ml-library-debugger", version.ref = "educational-ml-library" }
jackson-dataformat-yaml = { group = "com.fasterxml.jackson.dataformat", name = "jackson-dataformat-yaml", version.ref = "jackson" }
jackson-datatype-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jackson" }
jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jackson" }
jsoup = { group = "org.jsoup", name = "jsoup", version = "1.17.2" }
kotlin-css-jvm = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-css-jvm", version = "2026.4.12" }
jsoup = { group = "org.jsoup", name = "jsoup", version = "1.23.1" }
kotlin-css-jvm = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-css-jvm", version = "2026.8.4" }
kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.8.0" }
kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.9.0" }
logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }

# test dependencies
classgraph = { module = "io.github.classgraph:classgraph", version = "4.8.179" }
classgraph = { module = "io.github.classgraph:classgraph", version = "4.8.193" }
junit = { group = "junit", name = "junit", version = "4.13.2" }
kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" }
mockk = { group = "io.mockk", name = "mockk", version = "1.13.13" }
mockk = { group = "io.mockk", name = "mockk", version = "1.14.11" }
mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
openTest4J = { group = "org.opentest4j", name = "opentest4j", version = "1.3.0" }
hamcrest = { group = "org.hamcrest", name = "hamcrest", version="2.2" }
hamcrest = { group = "org.hamcrest", name = "hamcrest", version="3.0" }

[plugins]
intelliJPlatformPlugin = { id = "org.jetbrains.intellij.platform", version = "2.15.0" }
intelliJPlatformPlugin = { id = "org.jetbrains.intellij.platform", version = "2.18.1" }
kotlinPlugin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinSerializationPlugin = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
propertiesPlugin = { id = "net.saliman.properties", version = "1.6.0" }
testRetryPlugin = { id = "org.gradle.test-retry", version = "1.6.4" }
testRetryPlugin = { id = "org.gradle.test-retry", version = "1.6.5" }
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,28 @@ abstract class ItemContainer : StudyItem() {
return items.firstOrNull { it.name == name }
}

/**
* Adding an item also makes this container its [StudyItem.parent].
*
* Otherwise the item would stay in the container while [StudyItem.parent] throws, and every consumer walking up
* the tree (course view presentation, YAML reload, task description) breaks on an item that looks perfectly fine
* in [items]. [init] still (re)initializes a whole subtree, this only keeps a single insertion consistent.
*/
fun addItem(item: StudyItem) {
_items.add(item)
item.parent = this
}

fun addItem(index: Int, item: StudyItem) {
_items.add(index, item)
item.parent = this
}

fun replaceItem(existingItem: StudyItem, newItem: StudyItem) {
val index = _items.indexOf(existingItem)
if (index < 0) return
_items[index] = newItem
newItem.parent = this
}

fun removeItem(item: StudyItem) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ abstract class StudyItem() {
var id: Int = 0 // id on remote resource (Stepik, Marketplace)
var contentTags: List<String> = listOf()

/**
* `true` when this item's children could not be fully resolved during the last load: a child directory or its config
* file was missing, or a child failed to deserialize. Such an item holds fewer children than the config file on disk
* claims, so writing it back would persist the truncated `content:` list and permanently drop the missing children.
*
* [org.hyperskill.academy.learning.yaml.YamlFormatSynchronizer.saveItem] refuses to write the *structural* config
* while this is set; the remote config keeps being written, as it holds no `content:` list.
* It is recomputed on every load rather than latched, so a later complete load clears it again.
*
* Note this covers children only. A [org.hyperskill.academy.learning.courseFormat.tasks.Task] whose task files
* could not be resolved is deliberately *not* marked here: its `status` lives in the same config file, so blocking
* the save would stop the solved state from being persisted.
*/
@Transient
var isPartiallyLoaded: Boolean = false

@Transient
private var _parent: ItemContainer? = null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,34 +121,41 @@ fun <T> Response<T>.executeParsingErrors(omitErrors: Boolean = false): Result<Re
Err("${message("error.service.maintenance")}\n\n$error") // 502, 503
in HTTP_INTERNAL_ERROR..HTTP_VERSION ->
Err("${message("error.service.down")}\n\n$error") // 500x
HTTP_FORBIDDEN, HTTP_UNAUTHORIZED -> {
val errorMessage = processForbiddenErrorMessage(error) ?: message("error.access.denied")
Err(errorMessage)
}
// 401 means the access token is not valid anymore, and it's the generic message that makes
// `StepikBasedConnector.withTokenRefreshIfFailed` refresh the tokens and repeat the request
HTTP_UNAUTHORIZED -> Err(processErrorMessage(error, MESSAGE_FIELD) ?: message("error.access.denied"))

// Unlike 401, 403 is not about an expired token: the server explains why the request is not allowed,
// e.g. that the stage is locked behind a subscription, so its own text is the useful one
HTTP_FORBIDDEN -> Err(processErrorMessage(error, MESSAGE_FIELD, DETAIL_FIELD) ?: message("error.access.denied"))

HTTP_UNAVAILABLE_FOR_LEGAL_REASONS -> { // 451
LOG.warning(message("error.agreement.not.accepted"))
Err(fullErrorText)
}

in HTTP_BAD_REQUEST..HTTP_UNSUPPORTED_TYPE ->
Err(message("error.unexpected.error", error)) // 400x
in HTTP_BAD_REQUEST..HTTP_UNSUPPORTED_TYPE -> // 400x
Err(processErrorMessage(error, MESSAGE_FIELD, DETAIL_FIELD) ?: message("error.unexpected.error", error))
else -> {
LOG.warning("Code $code is not handled")
Err(message("error.unexpected.error", error))
}
}
}

private fun processForbiddenErrorMessage(jsonText: String): String? {
/**
* Extracts the explanation the server put into the error body, looking at [fields] in the given order.
* Returns `null` when the body is not a JSON object or none of the fields carries any text.
*/
private fun processErrorMessage(jsonText: String, vararg fields: String): String? {
return try {
val factory = JsonFactory()
val mapper = ObjectMapper(factory)
val module = SimpleModule()
mapper.registerModule(module)
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
val courseNode = mapper.readTree(jsonText) as ObjectNode
courseNode.get("message")?.asText()
val errorNode = mapper.readTree(jsonText) as ObjectNode
fields.firstNotNullOfOrNull { field -> errorNode.get(field)?.asText()?.takeIf { it.isNotBlank() } }
}
catch (_: ClassCastException) {
null
Expand All @@ -158,4 +165,7 @@ private fun processForbiddenErrorMessage(jsonText: String): String? {
}
}

private const val MESSAGE_FIELD = "message"
private const val DETAIL_FIELD = "detail"

const val HTTP_UNAVAILABLE_FOR_LEGAL_REASONS: Int = 451
2 changes: 1 addition & 1 deletion intellij-plugin/hs-Go/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ dependencies {
intellijIde(ideaVersion)
bundledModulesSince(ideaVersion, 262, "intellij.platform.smRunner", "intellij.platform.testRunner")

intellijPlugins(goPlugin, intelliLangPlugin)
intellijPlugins(goPlugin)
// Workaround to make tests work - the module is not loaded automatically
bundledModule("com.intellij.modules.ultimate")
testIntellijPlatformFramework(project)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,14 @@
package org.hyperskill.academy.java

import com.intellij.openapi.projectRoots.JavaSdk
import com.intellij.openapi.projectRoots.JavaSdkVersion
import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel
import org.hyperskill.academy.jvm.JavaVersionParseSuccess
import org.hyperskill.academy.jvm.JdkLanguageSettings
import org.hyperskill.academy.jvm.ParsedJavaVersion
import org.hyperskill.academy.learning.courseFormat.Course

open class JLanguageSettings : JdkLanguageSettings() {

// Note: setupProjectSdksModel is intentionally not overridden here.
// Adding SDK via model.addSdk() on EDT is prohibited in IntelliJ 2025.3+.
// Bundled JDK is added in addBundledJdkIfNeeded() which is called from background thread.

override fun addBundledJdkIfNeeded(model: ProjectSdksModel) {
val (jdkPath, sdk) = findBundledJdk(model) ?: return
if (sdk == null) {
model.addSdk(JavaSdk.getInstance(), jdkPath, null)
}
}

override fun minJvmSdkVersion(course: Course): ParsedJavaVersion {
override fun requiredJdkVersion(course: Course): ParsedJavaVersion {
val javaVersionDescription = course.languageVersion ?: return JavaVersionParseSuccess(DEFAULT_JAVA)
return ParsedJavaVersion.fromJavaSdkDescriptionString(javaVersionDescription)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import org.hyperskill.academy.java.JConfigurator
import org.hyperskill.academy.java.JCourseBuilder
import org.hyperskill.academy.jvm.JdkLanguageSettings
import org.hyperskill.academy.jvm.JdkProjectSettings
import org.hyperskill.academy.jvm.ParsedJavaVersion
import org.hyperskill.academy.jvm.gradle.GradleCourseBuilderBase
import org.hyperskill.academy.jvm.gradle.GradleHyperskillConfigurator
import org.hyperskill.academy.jvm.gradle.generation.GradleCourseProjectGenerator
import org.hyperskill.academy.jvm.requiredJdkVersion
import org.hyperskill.academy.learning.EduCourseBuilder
import org.hyperskill.academy.learning.EduNames
import org.hyperskill.academy.learning.courseFormat.Course
Expand Down Expand Up @@ -45,10 +45,9 @@ class JHyperskillConfigurator : GradleHyperskillConfigurator<JdkProjectSettings>
GradleCourseProjectGenerator(builder, course) {

override fun getJdk(settings: JdkProjectSettings): Sdk? {
return super.getJdk(settings) ?: JdkLanguageSettings.findSuitableJdk(
ParsedJavaVersion.fromJavaSdkDescriptionString(course.languageVersion),
settings.model
)
// `course.languageVersion` is the language level of a Hyperskill Java course ("11"), not the JDK its checker
// needs, so it must not be used here: it would accept a JDK the course cannot be checked with
return super.getJdk(settings) ?: JdkLanguageSettings.findSuitableJdk(course.requiredJdkVersion, settings.model)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import org.hyperskill.academy.jvm.hyperskillJdkVersion
import org.hyperskill.academy.learning.courseFormat.Course

class JHyperskillLanguageSettings : JLanguageSettings() {
override fun minJvmSdkVersion(course: Course): ParsedJavaVersion {
override fun requiredJdkVersion(course: Course): ParsedJavaVersion {
return JavaVersionParseSuccess(hyperskillJdkVersion)
}
}
5 changes: 5 additions & 0 deletions intellij-plugin/hs-Python/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ plugins {
id("intellij-plugin-module-conventions")
}

// `branches/261|262/src/com/jetbrains/python/**` holds copies of and shims around Python plugin internals
// (`PyTargetEnvCreationManager`, the `compat.kt` files). They call package-private and `internal` members of the
// Python plugin, which is only possible from that plugin's own package, so `verifyClasses` has to allow them.
ext[VERIFY_CLASSES_ALLOWED_PACKAGES] = "com.jetbrains.python"

private val pythonPlatformModuleDependenciesMarker = "<!-- PYTHON_PLATFORM_MODULE_DEPENDENCIES -->"
private val pythonPlatformModuleDependencies = if (environmentName.toInt() >= 262) {
listOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.hyperskill.academy.platform

/**
* `com.intellij.openapi.application.impl.TestOnlyThreading` only exists since 2025.3.
* On 2025.2 the caller does not hold the write intent lock while dispatching invocation events, so the action runs
* as is -- exactly what this code did before the lock dance was introduced in `Release fixes (#54)`.
*
* BACKCOMPAT: 252 -- drop this branch copy once 2025.2 is no longer supported.
*/
fun runWithoutWriteIntentLock(action: () -> Unit) {
action()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.hyperskill.academy.platform

import com.intellij.openapi.application.impl.TestOnlyThreading

/**
* On 2025.3 `releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack` is a Kotlin function
* taking `() -> T` and returning its result. Since 2026.1 it takes a `java.lang.Runnable` and returns nothing,
* and on 2025.2 the class does not exist at all -- hence one copy of this shim per branch.
*/
fun runWithoutWriteIntentLock(action: () -> Unit) {
TestOnlyThreading.releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack(action)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.hyperskill.academy.platform

import com.intellij.openapi.application.impl.TestOnlyThreading

/**
* Since 2026.1 `releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack` takes a
* `java.lang.Runnable` and returns nothing. On 2025.3 it takes a Kotlin `() -> T`, and on 2025.2 the class does not
* exist at all -- hence one copy of this shim per branch.
*/
fun runWithoutWriteIntentLock(action: () -> Unit) {
TestOnlyThreading.releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack(Runnable { action() })
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.hyperskill.academy.platform

import com.intellij.openapi.application.impl.TestOnlyThreading

/**
* Since 2026.1 `releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack` takes a
* `java.lang.Runnable` and returns nothing. On 2025.3 it takes a Kotlin `() -> T`, and on 2025.2 the class does not
* exist at all -- hence one copy of this shim per branch.
*/
fun runWithoutWriteIntentLock(action: () -> Unit) {
TestOnlyThreading.releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack(Runnable { action() })
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ buildscript {
}
}

## Lets Gradle download the Java toolchain requested in build.gradle instead of failing with
## "Toolchain download repositories have not been configured" when that JDK is not installed locally.
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}

## Should be the same as `org.hyperskill.academy.learning.courseGeneration.GeneratorUtils.sanitizeName`

static String sanitizeName(String name) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,12 @@ error.failed.to.post.solution=Failed to post solution
error.failed.to.post.solution.to=Failed to post solution to {0}
error.failed.to.post.solution.with.guide=Failed to post solution to {0}. For more information, \
see <a href=\"{1}\">the Troubleshooting guide</a>
# Ex.: Can't post a submission for this stage. Upgrade your subscription.<br/>For more information, \
# see <a href="">the Troubleshooting guide</a>
error.failed.to.post.solution.reason={0}<br/>For more information, \
see <a href=\"{1}\">the Troubleshooting guide</a>
# Ex.: Your solution was not accepted by JetBrains Academy
error.solution.rejected=Your solution was not accepted by {0}
hyperskill.error.empty.check.profile=Check profile is empty for task {0}. Please try to {1} or contact support.
error.failed.to.refresh.tokens=Failed to refresh tokens
error.invalid.rename.message=This rename operation can break the course
Expand Down
Loading
Loading