processor.php 24.6 KB
Newer Older
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Python predictions processor
 *
 * @package   mlbackend_python
 * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

namespace mlbackend_python;

defined('MOODLE_INTERNAL') || die();

/**
 * Python predictions processor.
 *
 * @package   mlbackend_python
 * @copyright 2016 David Monllao {@link http://www.davidmonllao.com}
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
36
class processor implements  \core_analytics\classifier, \core_analytics\regressor, \core_analytics\packable {
37

38
39
40
    /**
     * The required version of the python package that performs all calculations.
     */
41
    const REQUIRED_PIP_PACKAGE_VERSION = '2.3.0';
42

43
44
45
46
47
48
    /**
     * The python package is installed in a server.
     * @var bool
     */
    protected $useserver;

49
50
51
52
53
54
55
    /**
     * The path to the Python bin.
     *
     * @var string
     */
    protected $pathtopython;

56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
    /**
     * Remote server host
     * @var string
     */
    protected $host;

    /**
     * Remote server port
     * @var int
     */
    protected $port;

    /**
     * Whether to use http or https.
     * @var bool
     */
    protected $secure;

    /**
     * Server username.
     * @var string
     */
    protected $username;

    /**
     * Server password for $this->username.
     * @var string
     */
    protected $password;

86
87
    /**
     * The constructor.
88
     *
89
90
91
92
     */
    public function __construct() {
        global $CFG;

93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
        $config = get_config('mlbackend_python');

        $this->useserver = !empty($config->useserver);

        if (!$this->useserver) {
            // Set the python location if there is a value.
            if (!empty($CFG->pathtopython)) {
                $this->pathtopython = $CFG->pathtopython;
            }
        } else {
            $this->host = $config->host ?? '';
            $this->port = $config->port ?? '';
            $this->secure = $config->secure ?? false;
            $this->username = $config->username ?? '';
            $this->password = $config->password ?? '';
108
109
110
        }
    }

111
112
113
    /**
     * Is the plugin ready to be used?.
     *
114
     * @return bool|string Returns true on success, a string detailing the error otherwise
115
     */
116
    public function is_ready() {
117
118
119
120
121
122
123
124
125
126
127
128
129
130

        if (!$this->useserver) {
            return $this->is_webserver_ready();
        } else {
            return $this->is_python_server_ready();
        }
    }

    /**
     * Checks if the python package is available in the web server executing this script.
     *
     * @return bool|string Returns true on success, a string detailing the error otherwise
     */
    protected function is_webserver_ready() {
131
132
133
134
        if (empty($this->pathtopython)) {
            $settingurl = new \moodle_url('/admin/settings.php', array('section' => 'systempaths'));
            return get_string('pythonpathnotdefined', 'mlbackend_python', $settingurl->out());
        }
135

136
        // Check the installed pip package version.
137
        $cmd = "{$this->pathtopython} -m moodlemlbackend.version";
138
139
140
141
142
143
144
145
146
147

        $output = null;
        $exitcode = null;
        // Execute it sending the standard error to $output.
        $result = exec($cmd . ' 2>&1', $output, $exitcode);

        if ($exitcode != 0) {
            return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
        }

148
149
150
        $vercheck = self::check_pip_package_version($result);
        return $this->version_check_return($result, $vercheck);
    }
151

152
153
154
155
156
157
    /**
     * Checks if the server can be accessed.
     *
     * @return bool|string True or an error string.
     */
    protected function is_python_server_ready() {
158

159
160
        if (empty($this->host) || empty($this->port) || empty($this->username) || empty($this->password)) {
            return get_string('errornoconfigdata', 'mlbackend_python');
161
162
        }

163
164
165
166
167
168
169
170
171
172
        // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports.
        $curl = new \curl(['ignoresecurity' => true]);
        $responsebody = $curl->get($this->get_server_url('version')->out(false));
        if ($curl->info['http_code'] !== 200) {
            return get_string('errorserver', 'mlbackend_python', $this->server_error_str($curl->info['http_code'], $responsebody));
        }

        $vercheck = self::check_pip_package_version($responsebody);
        return $this->version_check_return($responsebody, $vercheck);

173
174
    }

175
176
177
    /**
     * Delete the model version output directory.
     *
178
     * @throws \moodle_exception
179
180
181
182
183
     * @param string $uniqueid
     * @param string $modelversionoutputdir
     * @return null
     */
    public function clear_model($uniqueid, $modelversionoutputdir) {
184
185
186
187
188
189
190
191
        if (!$this->useserver) {
            remove_dir($modelversionoutputdir);
        } else {
            // Use the server.

            $url = $this->get_server_url('deletemodel');
            list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]);
        }
192
193
194
195
196
    }

    /**
     * Delete the model output directory.
     *
197
     * @throws \moodle_exception
198
     * @param string $modeloutputdir
199
     * @param string $uniqueid
200
201
     * @return null
     */
202
203
204
205
206
207
208
209
    public function delete_output_dir($modeloutputdir, $uniqueid) {
        if (!$this->useserver) {
            remove_dir($modeloutputdir);
        } else {

            $url = $this->get_server_url('deletemodel');
            list($responsebody, $httpcode) = $this->server_request($url, 'post', ['uniqueid' => $uniqueid]);
        }
210
211
    }

212
213
214
215
216
217
218
219
    /**
     * Trains a machine learning algorithm with the provided dataset.
     *
     * @param string $uniqueid
     * @param \stored_file $dataset
     * @param string $outputdir
     * @return \stdClass
     */
220
    public function train_classification($uniqueid, \stored_file $dataset, $outputdir) {
221

222
223
        if (!$this->useserver) {
            // Use the local file system.
224

225
226
            list($result, $exitcode) = $this->exec_command('training', [$uniqueid, $outputdir,
                $this->get_file_path($dataset)], 'errornopredictresults');
227

228
229
        } else {
            // Use the server.
230

231
232
            $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir),
                'dataset' => $dataset];
233

234
235
            $url = $this->get_server_url('training');
            list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);
236
237
238
239
240
241
        }

        if (!$resultobj = json_decode($result)) {
            throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
        }

242
243
        if ($resultobj->status != 0) {
            $resultobj = $this->format_error_info($resultobj);
244
245
246
247
248
        }

        return $resultobj;
    }

249
    /**
250
     * Classifies the provided dataset samples.
251
252
253
254
255
256
     *
     * @param string $uniqueid
     * @param \stored_file $dataset
     * @param string $outputdir
     * @return \stdClass
     */
257
    public function classify($uniqueid, \stored_file $dataset, $outputdir) {
258

259
260
        if (!$this->useserver) {
            // Use the local file system.
261

262
263
            list($result, $exitcode) = $this->exec_command('prediction', [$uniqueid, $outputdir,
                $this->get_file_path($dataset)], 'errornopredictresults');
264

265
266
        } else {
            // Use the server.
267

268
269
            $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir),
                'dataset' => $dataset];
270

271
272
            $url = $this->get_server_url('prediction');
            list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);
273
274
275
276
277
278
        }

        if (!$resultobj = json_decode($result)) {
            throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
        }

279
280
281

        if ($resultobj->status != 0) {
            $resultobj = $this->format_error_info($resultobj);
282
283
284
285
286
        }

        return $resultobj;
    }

287
    /**
288
     * Evaluates this processor classification model using the provided supervised learning dataset.
289
290
291
292
293
294
     *
     * @param string $uniqueid
     * @param float $maxdeviation
     * @param int $niterations
     * @param \stored_file $dataset
     * @param string $outputdir
295
     * @param  string $trainedmodeldir
296
297
     * @return \stdClass
     */
298
299
    public function evaluate_classification($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
            $outputdir, $trainedmodeldir) {
300
        global $CFG;
301

302
303
        if (!$this->useserver) {
            // Use the local file system.
304

305
            $datasetpath = $this->get_file_path($dataset);
306

307
308
            $params = [$uniqueid, $outputdir, $datasetpath, \core_analytics\model::MIN_SCORE,
                $maxdeviation, $niterations];
309

310
311
312
            if ($trainedmodeldir) {
                $params[] = $trainedmodeldir;
            }
313

314
315
316
317
            list($result, $exitcode) = $this->exec_command('evaluation', $params, 'errornopredictresults');
            if (!$resultobj = json_decode($result)) {
                throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
            }
318

319
320
        } else {
            // Use the server.
321

322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
            $requestparams = ['uniqueid' => $uniqueid, 'minscore' => \core_analytics\model::MIN_SCORE,
                'maxdeviation' => $maxdeviation, 'niterations' => $niterations,
                'dirhash' => $this->hash_dir($outputdir), 'dataset' => $dataset];

            if ($trainedmodeldir) {
                $requestparams['trainedmodeldirhash'] = $this->hash_dir($trainedmodeldir);
            }

            $url = $this->get_server_url('evaluation');
            list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);

            if (!$resultobj = json_decode($result)) {
                throw new \moodle_exception('errorpredictwrongformat', 'analytics', '', json_last_error_msg());
            }

            // We need an extra request to get the resources generated during the evaluation process.

            // Directory to temporarly store the evaluation log zip returned by the server.
            $evaluationtmpdir = make_request_directory('mlbackend_python_evaluationlog');
            $evaluationzippath = $evaluationtmpdir . DIRECTORY_SEPARATOR . 'evaluationlog.zip';

            $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($outputdir),
            'runid' => $resultobj->runid];

            $url = $this->get_server_url('evaluationlog');
            list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams,
                ['filepath' => $evaluationzippath]);

            $rundir = $outputdir . DIRECTORY_SEPARATOR . 'logs' . DIRECTORY_SEPARATOR . $resultobj->runid;
            if (!mkdir($rundir, $CFG->directorypermissions, true)) {
                throw new \moodle_exception('errorexportmodelresult', 'analytics');
            }

            $zip = new \zip_packer();
            $success = $zip->extract_to_pathname($evaluationzippath, $rundir, null, null, true);
            if (!$success) {
                $a = 'The evaluation files can not be exported to ' . $rundir;
                throw new \moodle_exception('errorpredictionsprocessor', 'analytics', '', $a);
            }

            $resultobj->dir = $rundir;
363
364
        }

365
366
        $resultobj = $this->add_extra_result_info($resultobj);

367
368
369
        return $resultobj;
    }

370
371
372
373
374
375
376
377
378
379
380
381
    /**
     * Exports the machine learning model.
     *
     * @throws \moodle_exception
     * @param  string $uniqueid  The model unique id
     * @param  string $modeldir  The directory that contains the trained model.
     * @return string            The path to the directory that contains the exported model.
     */
    public function export(string $uniqueid, string $modeldir) : string {

        $exporttmpdir = make_request_directory('mlbackend_python_export');

382
383
        if (!$this->useserver) {
            // Use the local file system.
384

385
386
387
388
            // We include an exporttmpdir as we want to be sure that the file is not deleted after the
            // python process finishes.
            list($exportdir, $exitcode) = $this->exec_command('export', [$uniqueid, $modeldir, $exporttmpdir],
                'errorexportmodelresult');
389

390
391
392
            if ($exitcode != 0) {
                throw new \moodle_exception('errorexportmodelresult', 'analytics');
            }
393

394
395
        } else {
            // Use the server.
396

397
398
399
400
401
402
403
404
405
406
407
408
409
            $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir)];

            $exportzippath = $exporttmpdir . DIRECTORY_SEPARATOR . 'export.zip';
            $url = $this->get_server_url('export');
            list($result, $httpcode) = $this->server_request($url, 'download_one', $requestparams,
                ['filepath' => $exportzippath]);

            $exportdir = make_request_directory();
            $zip = new \zip_packer();
            $success = $zip->extract_to_pathname($exportzippath, $exportdir, null, null, true);
            if (!$success) {
                throw new \moodle_exception('errorexportmodelresult', 'analytics');
            }
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
        }

        return $exportdir;
    }

    /**
     * Imports the provided machine learning model.
     *
     * @param  string $uniqueid The model unique id
     * @param  string $modeldir  The directory that will contain the trained model.
     * @param  string $importdir The directory that contains the files to import.
     * @return bool Success
     */
    public function import(string $uniqueid, string $modeldir, string $importdir) : bool {

425
426
        if (!$this->useserver) {
            // Use the local file system.
427

428
429
            list($result, $exitcode) = $this->exec_command('import', [$uniqueid, $modeldir, $importdir],
                'errorimportmodelresult');
430

431
432
433
            if ($exitcode != 0) {
                throw new \moodle_exception('errorimportmodelresult', 'analytics');
            }
434

435
436
        } else {
            // Use the server.
437

438
439
440
441
442
443
444
445
446
447
448
            // Zip the $importdir to send a single file.
            $importzipfile = $this->zip_dir($importdir);
            if (!$importzipfile) {
                // There was an error zipping the directory.
                throw new \moodle_exception('errorimportmodelresult', 'analytics');
            }

            $requestparams = ['uniqueid' => $uniqueid, 'dirhash' => $this->hash_dir($modeldir),
                'importzip' => curl_file_create($importzipfile, null, 'import.zip')];
            $url = $this->get_server_url('import');
            list($result, $httpcode) = $this->server_request($url, 'post', $requestparams);
449
450
        }

451
        return (bool)$result;
452
453
    }

454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
    /**
     * Train this processor regression model using the provided supervised learning dataset.
     *
     * @throws new \coding_exception
     * @param string $uniqueid
     * @param \stored_file $dataset
     * @param string $outputdir
     * @return \stdClass
     */
    public function train_regression($uniqueid, \stored_file $dataset, $outputdir) {
        throw new \coding_exception('This predictor does not support regression yet.');
    }

    /**
     * Estimates linear values for the provided dataset samples.
     *
     * @throws new \coding_exception
     * @param string $uniqueid
     * @param \stored_file $dataset
     * @param mixed $outputdir
     * @return void
     */
    public function estimate($uniqueid, \stored_file $dataset, $outputdir) {
        throw new \coding_exception('This predictor does not support regression yet.');
    }

    /**
     * Evaluates this processor regression model using the provided supervised learning dataset.
     *
     * @throws new \coding_exception
     * @param string $uniqueid
     * @param float $maxdeviation
     * @param int $niterations
     * @param \stored_file $dataset
     * @param string $outputdir
489
     * @param  string $trainedmodeldir
490
491
     * @return \stdClass
     */
492
493
    public function evaluate_regression($uniqueid, $maxdeviation, $niterations, \stored_file $dataset,
            $outputdir, $trainedmodeldir) {
494
495
496
        throw new \coding_exception('This predictor does not support regression yet.');
    }

497
498
499
500
501
502
    /**
     * Returns the path to the dataset file.
     *
     * @param \stored_file $file
     * @return string
     */
503
504
505
506
507
    protected function get_file_path(\stored_file $file) {
        // From moodle filesystem to the local file system.
        // This is not ideal, but there is no read access to moodle filesystem files.
        return $file->copy_content_to_temp('core_analytics');
    }
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542

    /**
     * Check that the given package version can be used and return the error status.
     *
     * When evaluating the version, we assume the sematic versioning scheme as described at
     * https://semver.org/.
     *
     * @param string $actual The actual Python package version
     * @param string $required The required version of the package
     * @return int -1 = actual version is too low, 1 = actual version too high, 0 = actual version is ok
     */
    public static function check_pip_package_version($actual, $required = self::REQUIRED_PIP_PACKAGE_VERSION) {

        if (empty($actual)) {
            return -1;
        }

        if (version_compare($actual, $required, '<')) {
            return -1;
        }

        $parts = explode('.', $required);
        $requiredapiver = reset($parts);

        $parts = explode('.', $actual);
        $actualapiver = reset($parts);

        if ($requiredapiver > 0 || $actualapiver > 1) {
            if (version_compare($actual, $requiredapiver + 1, '>=')) {
                return 1;
            }
        }

        return 0;
    }
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747

    /**
     * Executes the specified module.
     *
     * @param  string $modulename
     * @param  array  $params
     * @param  string $errorlangstr
     * @return array [0] is the result body and [1] the exit code.
     */
    protected function exec_command(string $modulename, array $params, string $errorlangstr) {

        $cmd = $this->pathtopython . ' -m moodlemlbackend.' . $modulename . ' ';
        foreach ($params as $param) {
            $cmd .= escapeshellarg($param) . ' ';
        }

        if (!PHPUNIT_TEST && CLI_SCRIPT) {
            debugging($cmd, DEBUG_DEVELOPER);
        }

        $output = null;
        $exitcode = null;
        $result = exec($cmd, $output, $exitcode);

        if (!$result) {
            throw new \moodle_exception($errorlangstr, 'analytics');
        }

        return [$result, $exitcode];
    }

    /**
     * Formats the errors and info in a single info string.
     *
     * @param  \stdClass $resultobj
     * @return \stdClass
     */
    private function format_error_info(\stdClass $resultobj) {
        if (!empty($resultobj->errors)) {
            $errors = $resultobj->errors;
            if (is_array($errors)) {
                $errors = implode(', ', $errors);
            }
        } else if (!empty($resultobj->info)) {
            // Show info if no errors are returned.
            $errors = $resultobj->info;
            if (is_array($errors)) {
                $errors = implode(', ', $errors);
            }
        }
        $resultobj->info = array(get_string('errorpredictionsprocessor', 'analytics', $errors));

        return $resultobj;
    }

    /**
     * Returns the url to the python ML server.
     *
     * @param  string|null $path
     * @return \moodle_url
     */
    private function get_server_url(?string $path = null) {
        $protocol = !empty($this->secure) ? 'https' : 'http';
        $url = $protocol . '://' . rtrim($this->host, '/');
        if (!empty($this->port)) {
            $url .= ':' . $this->port;
        }

        if ($path) {
            $url .= '/' . $path;
        }

        return new \moodle_url($url);
    }

    /**
     * Sends a request to the python ML server.
     *
     * @param  \moodle_url      $url            The requested url in the python ML server
     * @param  string           $method         The curl method to use
     * @param  array            $requestparams  Curl request params
     * @param  array|null       $options        Curl request options
     * @return array                            [0] for the response body and [1] for the http code
     */
    protected function server_request($url, string $method, array $requestparams, ?array $options = null) {

        if ($method !== 'post' && $method !== 'get' && $method !== 'download_one') {
            throw new \coding_exception('Incorrect request method provided. Only "get", "post" and "download_one"
                actions are available.');
        }

        // Connection is allowed to use 'localhost' and other potentially blocked hosts/ports.
        $curl = new \curl(['ignoresecurity' => true]);

        $authorization = $this->username . ':' . $this->password;
        $curl->setHeader('Authorization: Basic ' . base64_encode($authorization));

        $responsebody = $curl->{$method}($url, $requestparams, $options);

        if ($curl->info['http_code'] !== 200) {
            throw new \moodle_exception('errorserver', 'mlbackend_python', '',
                $this->server_error_str($curl->info['http_code'], $responsebody));
        }

        return [$responsebody, $curl->info['http_code']];
    }

    /**
     * Adds extra information to results info.
     *
     * @param  \stdClass $resultobj
     * @return \stdClass
     */
    protected function add_extra_result_info(\stdClass $resultobj): \stdClass {

        if (!empty($resultobj->dir)) {
            $dir = $resultobj->dir . DIRECTORY_SEPARATOR . 'tensor';
            $resultobj->info[] = get_string('tensorboardinfo', 'mlbackend_python', $dir);
        }
        return $resultobj;
    }

    /**
     * Returns the proper return value for the version checking.
     *
     * @param  string $actual   Actual moodlemlbackend version
     * @param  int    $vercheck Version checking result
     * @return true|string      Returns true on success, a string detailing the error otherwise
     */
    private function version_check_return($actual, $vercheck) {

        if ($vercheck === 0) {
            return true;
        }

        if ($actual) {
            $a = [
                'installed' => $actual,
                'required' => self::REQUIRED_PIP_PACKAGE_VERSION,
            ];

            if ($vercheck < 0) {
                return get_string('packageinstalledshouldbe', 'mlbackend_python', $a);

            } else if ($vercheck > 0) {
                return get_string('packageinstalledtoohigh', 'mlbackend_python', $a);
            }
        }

        if (!$this->useserver) {
            $cmd = "{$this->pathtopython} -m moodlemlbackend.version";
        } else {
            // We can't not know which is the python bin in the python ML server, the most likely
            // value is 'python'.
            $cmd = "python -m moodlemlbackend.version";
        }
        return get_string('pythonpackagenotinstalled', 'mlbackend_python', $cmd);
    }

    /**
     * Hashes the provided dir as a string.
     *
     * @param  string $dir Directory path
     * @return string Hash
     */
    private function hash_dir(string $dir) {
        return md5($dir);
    }

    /**
     * Zips the provided directory.
     *
     * @param  string $dir Directory path
     * @return string The zip filename
     */
    private function zip_dir(string $dir) {

        $ziptmpdir = make_request_directory('mlbackend_python');
        $ziptmpfile = $ziptmpdir . DIRECTORY_SEPARATOR . 'mlbackend.zip';

        $files = get_directory_list($dir);
        $zipfiles = [];
        foreach ($files as $file) {
            $fullpath = $dir . DIRECTORY_SEPARATOR . $file;
            // Use the relative path to the file as the path in the zip.
            $zipfiles[$file] = $fullpath;
        }

        $zip = new \zip_packer();
        if (!$zip->archive_to_pathname($zipfiles, $ziptmpfile)) {
            return false;
        }

        return $ziptmpfile;
    }

    /**
     * Error string for httpcode !== 200
     *
     * @param int       $httpstatuscode The HTTP status code
     * @param string    $responsebody   The body of the response
     */
    private function server_error_str(int $httpstatuscode, string $responsebody): string {
        return 'HTTP status code ' . $httpstatuscode . ': ' . $responsebody;
    }
748
}