Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Python predictions processor
  19   *
  20   * @package   mlbackend_python
  21   * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   */
  24  
  25  namespace mlbackend_python;
  26  
  27  defined('MOODLE_INTERNAL') || die();
  28  
  29  /**
  30   * Python predictions processor.
  31   *
  32   * @package   mlbackend_python
  33   * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
  34   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  35   */
  36  class processor implements  \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable {
  37  
  38      /**
  39       * The required version of the python package that performs all calculations.
  40       */
  41      const REQUIRED_PIP_PACKAGE_VERSION = '2.6.6';
  42  
  43      /**
  44       * The python package is installed in a server.
  45       * @var bool
  46       */
  47      protected $useserver;
  48  
  49      /**
  50       * The path to the Python bin.
  51       *
  52       * @var string
  53       */
  54      protected $pathtopython;
  55  
  56      /**
  57       * Remote server host
  58       * @var string
  59       */
  60      protected $host;
  61  
  62      /**
  63       * Remote server port
  64       * @var int
  65       */
  66      protected $port;
  67  
  68      /**
  69       * Whether to use http or https.
  70       * @var bool
  71       */
  72      protected $secure;
  73  
  74      /**
  75       * Server username.
  76       * @var string
  77       */
  78      protected $username;
  79  
  80      /**
  81       * Server password for $this->username.
  82       * @var string
  83       */
  84      protected $password;
  85  
  86      /**
  87       * The constructor.
  88       *
  89       */
  90      public function __construct() {
  91          global $CFG;
  92  
  93          $config = get_config('mlbackend_python');
  94  
  95          $this->useserver = !empty($config->useserver);
  96  
  97          if (!$this->useserver) {
  98              // Set the python location if there is a value.
  99              if (!empty($CFG->pathtopython)) {
 100                  $this->pathtopython = $CFG->pathtopython;
 101              }
 102          } else {
 103              $this->host = $config->host ?? '';
 104              $this->port = $config->port ?? '';
 105              $this->secure = $config->secure ?? false;
 106              $this->username = $config->username ?? '';
 107              $this->password = $config->password ?? '';
 108          }
 109      }
 110  
 111      /**
 112       * Is the plugin ready to be used?.
 113       *
 114       * @return bool|string Returns true on success, a string detailing the error otherwise
 115       */
 116      public function is_ready() {
 117  
 118          if (!$this->useserver) {
 119              return $this->is_webserver_ready();
 120          } else {
 121              return $this->is_python_server_ready();
 122          }
 123      }
 124  
 125      /**
 126       * Checks if the python package is available in the web server executing this script.
 127       *
 128       * @return bool|string Returns true on success, a string detailing the error otherwise
 129       */
 130      protected function is_webserver_ready() {
 131          if (empty($this->pathtopython)) {
 132              $settingurl = new \moodle_url('/admin/settings.php', array('section' => 'systempaths'));
 133              return get_string('pythonpathnotdefined', 'mlbackend_python', $settingurl->out());
 134          }
 135  
 136          // Check the installed pip package version.
 137          $cmd = "{$this->pathtopython} -m moodlemlbackend.version";
 138  
 139          $output = null;
 140          $exitcode = null;
 141          // Execute it sending the standard error to $output.
 142          $result = exec($cmd . ' 2>&1', $output, $exitcode);
 143  
 144          if ($exitcode != 0) {
 145              return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
 146          }
 147  
 148          $vercheck = self::check_pip_package_version($result);
 149          return $this->version_check_return($result, $vercheck);
 150      }
 151  
 152      /**
 153       * Checks if the server can be accessed.
 154       *
 155       * @return bool|string True or an error string.
 156       */
 157      protected function is_python_server_ready() {
 158  
 159          if (empty($this->host) || empty($this->port) || empty($this->username) || empty($this->password)) {
 160              return get_string('errornoconfigdata', 'mlbackend_python');
 161          }
 162  
 163          // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports.
 164          $curl = new \curl(['ignoresecurity' => true]);
 165          $responsebody = $curl->get($this->get_server_url('version')->out(false));
 166          if ($curl->info['http_code'] !== 200) {
 167              return get_string('errorserver', 'mlbackend_python', $this->server_error_str($curl->info['http_code'], $responsebody));
 168          }
 169  
 170          $vercheck = self::check_pip_package_version($responsebody);
 171          return $this->version_check_return($responsebody, $vercheck);
 172  
 173      }
 174  
 175      /**
 176       * Delete the model version output directory.
 177       *
 178       * @throws \moodle_exception
 179       * @param string $uniqueid
 180       * @param string $modelversionoutputdir
 181       * @return null
 182       */
 183      public function clear_model($uniqueid, $modelversionoutputdir) {
 184          if (!$this->useserver) {
 185              remove_dir($modelversionoutputdir);
 186          } else {
 187              // Use the server.
 188  
 189              $url = $this->get_server_url('deletemodel');
 190              list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]);
 191          }
 192      }
 193  
 194      /**
 195       * Delete the model output directory.
 196       *
 197       * @throws \moodle_exception
 198       * @param string $modeloutputdir
 199       * @param string $uniqueid
 200       * @return null
 201       */
 202      public function delete_output_dir($modeloutputdir, $uniqueid) {
 203          if (!$this->useserver) {
 204              remove_dir($modeloutputdir);
 205          } else {
 206  
 207              $url = $this->get_server_url('deletemodel');
 208              list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]);
 209          }
 210      }
 211  
 212      /**
 213       * Trains a machine learning algorithm with the provided dataset.
 214       *
 215       * @param string $uniqueid
 216       * @param \stored_file $dataset
 217       * @param string $outputdir
 218       * @return \stdClass
 219       */
 220      public function train_classification($uniqueid, \stored_file $dataset, $outputdir) {
 221  
 222          if (!$this->useserver) {
 223              // Use the local file system.
 224  
 225              list($result, $exitcode) = $this->exec_command('training', [$uniqueid, $outputdir,
 226                  $this->get_file_path($dataset)], 'errornopredictresults');
 227  
 228          } else {
 229              // Use the server.
 230  
 231              $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir),
 232                  'dataset' => $dataset];
 233  
 234              $url = $this->get_server_url('training');
 235              list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);
 236          }
 237  
 238          if (!$resultobj = json_decode($result)) {
 239              throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
 240          }
 241  
 242          if ($resultobj->status != 0) {
 243              $resultobj = $this->format_error_info($resultobj);
 244          }
 245  
 246          return $resultobj;
 247      }
 248  
 249      /**
 250       * Classifies the provided dataset samples.
 251       *
 252       * @param string $uniqueid
 253       * @param \stored_file $dataset
 254       * @param string $outputdir
 255       * @return \stdClass
 256       */
 257      public function classify($uniqueid, \stored_file $dataset, $outputdir) {
 258  
 259          if (!$this->useserver) {
 260              // Use the local file system.
 261  
 262              list($result, $exitcode) = $this->exec_command('prediction', [$uniqueid, $outputdir,
 263                  $this->get_file_path($dataset)], 'errornopredictresults');
 264  
 265          } else {
 266              // Use the server.
 267  
 268              $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir),
 269                  'dataset' => $dataset];
 270  
 271              $url = $this->get_server_url('prediction');
 272              list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);
 273          }
 274  
 275          if (!$resultobj = json_decode($result)) {
 276              throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
 277          }
 278  
 279          if ($resultobj->status != 0) {
 280              $resultobj = $this->format_error_info($resultobj);
 281          }
 282  
 283          return $resultobj;
 284      }
 285  
 286      /**
 287       * Evaluates this processor classification model using the provided supervised learning dataset.
 288       *
 289       * @param string $uniqueid
 290       * @param float $maxdeviation
 291       * @param int $niterations
 292       * @param \stored_file $dataset
 293       * @param string $outputdir
 294       * @param  string $trainedmodeldir
 295       * @return \stdClass
 296       */
 297      public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
 298              $outputdir, $trainedmodeldir) {
 299          global $CFG;
 300  
 301          if (!$this->useserver) {
 302              // Use the local file system.
 303  
 304              $datasetpath = $this->get_file_path($dataset);
 305  
 306              $params = [$uniqueid, $outputdir, $datasetpath, \core_analytics\model::MIN_SCORE,
 307                  $maxdeviation, $niterations];
 308  
 309              if ($trainedmodeldir) {
 310                  $params[] = $trainedmodeldir;
 311              }
 312  
 313              list($result, $exitcode) = $this->exec_command('evaluation', $params, 'errornopredictresults');
 314              if (!$resultobj = json_decode($result)) {
 315                  throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
 316              }
 317  
 318          } else {
 319              // Use the server.
 320  
 321              $requestparams = ['uniqueid' => $uniqueid, 'minscore' => \core_analytics\model::MIN_SCORE,
 322                  'maxdeviation' => $maxdeviation, 'niterations' => $niterations,
 323                  'dirhash' => $this->hash_dir($outputdir), 'dataset' => $dataset];
 324  
 325              if ($trainedmodeldir) {
 326                  $requestparams['trainedmodeldirhash'] = $this->hash_dir($trainedmodeldir);
 327              }
 328  
 329              $url = $this->get_server_url('evaluation');
 330              list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);
 331  
 332              if (!$resultobj = json_decode($result)) {
 333                  throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
 334              }
 335  
 336              // We need an extra request to get the resources generated during the evaluation process.
 337  
 338              // Directory to temporarly store the evaluation log zip returned by the server.
 339              $evaluationtmpdir = make_request_directory();
 340              $evaluationzippath = $evaluationtmpdir . DIRECTORY_SEPARATOR . 'evaluationlog.zip';
 341  
 342              $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir),
 343              'runid' => $resultobj->runid];
 344  
 345              $url = $this->get_server_url('evaluationlog');
 346              list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams,
 347                  ['filepath' => $evaluationzippath]);
 348  
 349              $rundir = $outputdir . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . $resultobj->runid;
 350              if (!mkdir($rundir, $CFG->directorypermissions, true)) {
 351                  throw new \moodle_exception('errorexportmodelresult', 'analytics');
 352              }
 353  
 354              $zip = new \zip_packer();
 355              $success = $zip->extract_to_pathname($evaluationzippath, $rundir, null, null, true);
 356              if (!$success) {
 357                  $a = 'The evaluation files can not be exported to ' . $rundir;
 358                  throw new \moodle_exception('errorpredictionsprocessor', 'analytics', '', $a);
 359              }
 360  
 361              $resultobj->dir = $rundir;
 362          }
 363  
 364          $resultobj = $this->add_extra_result_info($resultobj);
 365  
 366          return $resultobj;
 367      }
 368  
 369      /**
 370       * Exports the machine learning model.
 371       *
 372       * @throws \moodle_exception
 373       * @param  string $uniqueid  The model unique id
 374       * @param  string $modeldir  The directory that contains the trained model.
 375       * @return string            The path to the directory that contains the exported model.
 376       */
 377      public function export(string $uniqueid, string $modeldir) : string {
 378  
 379          $exporttmpdir = make_request_directory();
 380  
 381          if (!$this->useserver) {
 382              // Use the local file system.
 383  
 384              // We include an exporttmpdir as we want to be sure that the file is not deleted after the
 385              // python process finishes.
 386              list($exportdir, $exitcode) = $this->exec_command('export', [$uniqueid, $modeldir, $exporttmpdir],
 387                  'errorexportmodelresult');
 388  
 389              if ($exitcode != 0) {
 390                  throw new \moodle_exception('errorexportmodelresult', 'analytics');
 391              }
 392  
 393          } else {
 394              // Use the server.
 395  
 396              $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir)];
 397  
 398              $exportzippath = $exporttmpdir . DIRECTORY_SEPARATOR . 'export.zip';
 399              $url = $this->get_server_url('export');
 400              list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams,
 401                  ['filepath' => $exportzippath]);
 402  
 403              $exportdir = make_request_directory();
 404              $zip = new \zip_packer();
 405              $success = $zip->extract_to_pathname($exportzippath, $exportdir, null, null, true);
 406              if (!$success) {
 407                  throw new \moodle_exception('errorexportmodelresult', 'analytics');
 408              }
 409          }
 410  
 411          return $exportdir;
 412      }
 413  
 414      /**
 415       * Imports the provided machine learning model.
 416       *
 417       * @param  string $uniqueid The model unique id
 418       * @param  string $modeldir  The directory that will contain the trained model.
 419       * @param  string $importdir The directory that contains the files to import.
 420       * @return bool Success
 421       */
 422      public function import(string $uniqueid, string $modeldir, string $importdir) : bool {
 423  
 424          if (!$this->useserver) {
 425              // Use the local file system.
 426  
 427              list($result, $exitcode) = $this->exec_command('import', [$uniqueid, $modeldir, $importdir],
 428                  'errorimportmodelresult');
 429  
 430              if ($exitcode != 0) {
 431                  throw new \moodle_exception('errorimportmodelresult', 'analytics');
 432              }
 433  
 434          } else {
 435              // Use the server.
 436  
 437              // Zip the $importdir to send a single file.
 438              $importzipfile = $this->zip_dir($importdir);
 439              if (!$importzipfile) {
 440                  // There was an error zipping the directory.
 441                  throw new \moodle_exception('errorimportmodelresult', 'analytics');
 442              }
 443  
 444              $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir),
 445                  'importzip' => curl_file_create($importzipfile, null, 'import.zip')];
 446              $url = $this->get_server_url('import');
 447              list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);
 448          }
 449  
 450          return (bool)$result;
 451      }
 452  
 453      /**
 454       * Train this processor regression model using the provided supervised learning dataset.
 455       *
 456       * @throws new \coding_exception
 457       * @param string $uniqueid
 458       * @param \stored_file $dataset
 459       * @param string $outputdir
 460       * @return \stdClass
 461       */
 462      public function train_regression($uniqueid, \stored_file $dataset, $outputdir) {
 463          throw new \coding_exception('This predictor does not support regression yet.');
 464      }
 465  
 466      /**
 467       * Estimates linear values for the provided dataset samples.
 468       *
 469       * @throws new \coding_exception
 470       * @param string $uniqueid
 471       * @param \stored_file $dataset
 472       * @param mixed $outputdir
 473       * @return void
 474       */
 475      public function estimate($uniqueid, \stored_file $dataset, $outputdir) {
 476          throw new \coding_exception('This predictor does not support regression yet.');
 477      }
 478  
 479      /**
 480       * Evaluates this processor regression model using the provided supervised learning dataset.
 481       *
 482       * @throws new \coding_exception
 483       * @param string $uniqueid
 484       * @param float $maxdeviation
 485       * @param int $niterations
 486       * @param \stored_file $dataset
 487       * @param string $outputdir
 488       * @param  string $trainedmodeldir
 489       * @return \stdClass
 490       */
 491      public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
 492              $outputdir, $trainedmodeldir) {
 493          throw new \coding_exception('This predictor does not support regression yet.');
 494      }
 495  
 496      /**
 497       * Returns the path to the dataset file.
 498       *
 499       * @param \stored_file $file
 500       * @return string
 501       */
 502      protected function get_file_path(\stored_file $file) {
 503          // From moodle filesystem to the local file system.
 504          // This is not ideal, but there is no read access to moodle filesystem files.
 505          return $file->copy_content_to_temp('core_analytics');
 506      }
 507  
 508      /**
 509       * Check that the given package version can be used and return the error status.
 510       *
 511       * When evaluating the version, we assume the sematic versioning scheme as described at
 512       * https://semver.org/.
 513       *
 514       * @param string $actual The actual Python package version
 515       * @param string $required The required version of the package
 516       * @return int -1 = actual version is too low, 1 = actual version too high, 0 = actual version is ok
 517       */
 518      public static function check_pip_package_version($actual, $required = self::REQUIRED_PIP_PACKAGE_VERSION) {
 519  
 520          if (empty($actual)) {
 521              return -1;
 522          }
 523  
 524          if (version_compare($actual, $required, '<')) {
 525              return -1;
 526          }
 527  
 528          $parts = explode('.', $required);
 529          $requiredapiver = reset($parts);
 530  
 531          $parts = explode('.', $actual);
 532          $actualapiver = reset($parts);
 533  
 534          if ($requiredapiver > 0 || $actualapiver > 1) {
 535              if (version_compare($actual, $requiredapiver + 1, '>=')) {
 536                  return 1;
 537              }
 538          }
 539  
 540          return 0;
 541      }
 542  
 543      /**
 544       * Executes the specified module.
 545       *
 546       * @param  string $modulename
 547       * @param  array  $params
 548       * @param  string $errorlangstr
 549       * @return array [0] is the result body and [1] the exit code.
 550       */
 551      protected function exec_command(string $modulename, array $params, string $errorlangstr) {
 552  
 553          $cmd = $this->pathtopython . ' -m moodlemlbackend.' . $modulename . ' ';
 554          foreach ($params as $param) {
 555              $cmd .= escapeshellarg($param) . ' ';
 556          }
 557  
 558          if (!PHPUNIT_TEST && CLI_SCRIPT) {
 559              debugging($cmd, DEBUG_DEVELOPER);
 560          }
 561  
 562          $output = null;
 563          $exitcode = null;
 564          $result = exec($cmd, $output, $exitcode);
 565  
 566          if (!$result) {
 567              throw new \moodle_exception($errorlangstr, 'analytics');
 568          }
 569  
 570          return [$result, $exitcode];
 571      }
 572  
 573      /**
 574       * Formats the errors and info in a single info string.
 575       *
 576       * @param  \stdClass $resultobj
 577       * @return \stdClass
 578       */
 579      private function format_error_info(\stdClass $resultobj) {
 580          if (!empty($resultobj->errors)) {
 581              $errors = $resultobj->errors;
 582              if (is_array($errors)) {
 583                  $errors = implode(', ', $errors);
 584              }
 585          } else if (!empty($resultobj->info)) {
 586              // Show info if no errors are returned.
 587              $errors = $resultobj->info;
 588              if (is_array($errors)) {
 589                  $errors = implode(', ', $errors);
 590              }
 591          }
 592          $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));
 593  
 594          return $resultobj;
 595      }
 596  
 597      /**
 598       * Returns the url to the python ML server.
 599       *
 600       * @param  string|null $path
 601       * @return \moodle_url
 602       */
 603      private function get_server_url(?string $path = null) {
 604          $protocol = !empty($this->secure) ? 'https' : 'http';
 605          $url = $protocol . '://' . rtrim($this->host, '/');
 606          if (!empty($this->port)) {
 607              $url .= ':' . $this->port;
 608          }
 609  
 610          if ($path) {
 611              $url .= '/' . $path;
 612          }
 613  
 614          return new \moodle_url($url);
 615      }
 616  
 617      /**
 618       * Sends a request to the python ML server.
 619       *
 620       * @param  \moodle_url      $url            The requested url in the python ML server
 621       * @param  string           $method         The curl method to use
 622       * @param  array            $requestparams  Curl request params
 623       * @param  array|null       $options        Curl request options
 624       * @return array                            [0] for the response body and [1] for the http code
 625       */
 626      protected function server_request($url, string $method, array $requestparams, ?array $options = null) {
 627  
 628          if ($method !== 'post' && $method !== 'get' && $method !== 'download_one') {
 629              throw new \coding_exception('Incorrect request method provided. Only "get", "post" and "download_one"
 630                  actions are available.');
 631          }
 632  
 633          // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports.
 634          $curl = new \curl(['ignoresecurity' => true]);
 635  
 636          $authorization = $this->username . ':' . $this->password;
 637          $curl->setHeader('Authorization: Basic ' . base64_encode($authorization));
 638  
 639          $responsebody = $curl->{$method}($url, $requestparams, $options);
 640  
 641          if ($curl->info['http_code'] !== 200) {
 642              throw new \moodle_exception('errorserver', 'mlbackend_python', '',
 643                  $this->server_error_str($curl->info['http_code'], $responsebody));
 644          }
 645  
 646          return [$responsebody, $curl->info['http_code']];
 647      }
 648  
 649      /**
 650       * Adds extra information to results info.
 651       *
 652       * @param  \stdClass $resultobj
 653       * @return \stdClass
 654       */
 655      protected function add_extra_result_info(\stdClass $resultobj): \stdClass {
 656  
 657          if (!empty($resultobj->dir)) {
 658              $dir = $resultobj->dir . DIRECTORY_SEPARATOR . 'tensor';
 659              $resultobj->info[] = get_string('tensorboardinfo', 'mlbackend_python', $dir);
 660          }
 661          return $resultobj;
 662      }
 663  
 664      /**
 665       * Returns the proper return value for the version checking.
 666       *
 667       * @param  string $actual   Actual moodlemlbackend version
 668       * @param  int    $vercheck Version checking result
 669       * @return true|string      Returns true on success, a string detailing the error otherwise
 670       */
 671      private function version_check_return($actual, $vercheck) {
 672  
 673          if ($vercheck === 0) {
 674              return true;
 675          }
 676  
 677          if ($actual) {
 678              $a = [
 679                  'installed' => $actual,
 680                  'required' => self::REQUIRED_PIP_PACKAGE_VERSION,
 681              ];
 682  
 683              if ($vercheck < 0) {
 684                  return get_string('packageinstalledshouldbe', 'mlbackend_python', $a);
 685  
 686              } else if ($vercheck > 0) {
 687                  return get_string('packageinstalledtoohigh', 'mlbackend_python', $a);
 688              }
 689          }
 690  
 691          if (!$this->useserver) {
 692              $cmd = "{$this->pathtopython} -m moodlemlbackend.version";
 693          } else {
 694              // We can't not know which is the python bin in the python ML server, the most likely
 695              // value is 'python'.
 696              $cmd = "python -m moodlemlbackend.version";
 697          }
 698          return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
 699      }
 700  
 701      /**
 702       * Hashes the provided dir as a string.
 703       *
 704       * @param  string $dir Directory path
 705       * @return string Hash
 706       */
 707      private function hash_dir(string $dir) {
 708          return md5($dir);
 709      }
 710  
 711      /**
 712       * Zips the provided directory.
 713       *
 714       * @param  string $dir Directory path
 715       * @return string The zip filename
 716       */
 717      private function zip_dir(string $dir) {
 718  
 719          $ziptmpdir = make_request_directory();
 720          $ziptmpfile = $ziptmpdir . DIRECTORY_SEPARATOR . 'mlbackend.zip';
 721  
 722          $files = get_directory_list($dir);
 723          $zipfiles = [];
 724          foreach ($files as $file) {
 725              $fullpath = $dir . DIRECTORY_SEPARATOR . $file;
 726              // Use the relative path to the file as the path in the zip.
 727              $zipfiles[$file] = $fullpath;
 728          }
 729  
 730          $zip = new \zip_packer();
 731          if (!$zip->archive_to_pathname($zipfiles, $ziptmpfile)) {
 732              return false;
 733          }
 734  
 735          return $ziptmpfile;
 736      }
 737  
 738      /**
 739       * Error string for httpcode !== 200
 740       *
 741       * @param int       $httpstatuscode The HTTP status code
 742       * @param string    $responsebody   The body of the response
 743       */
 744      private function server_error_str(int $httpstatuscode, string $responsebody): string {
 745          return 'HTTP status code ' . $httpstatuscode . ': ' . $responsebody;
 746      }
 747  }