Dodana logika vrste, ustvarjanje job-ov, urejen in popravljen swagger.yaml, poizvedba koncanih job-ov, protibitev statusa ce je koncano ali ne.
This commit is contained in:
+4
-1
@@ -67,4 +67,7 @@ target/
|
|||||||
.idea
|
.idea
|
||||||
|
|
||||||
# Related to Development folder
|
# Related to Development folder
|
||||||
mnt/
|
mnt/
|
||||||
|
|
||||||
|
gen/
|
||||||
|
swagger_server/requets_db/dbs/*
|
||||||
+62
@@ -11,6 +11,46 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
|
"/job/{job_id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"jobs"
|
||||||
|
],
|
||||||
|
"summary": "Vrne status",
|
||||||
|
"operationId": "getJobStatus",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "job_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int64"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "show_estimated_completion",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"description": "Calculate estimate time remaining based on various factors (could be inaccurate)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/JobResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/oznaciBesedilo": {
|
"/oznaciBesedilo": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -667,6 +707,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"JobResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"finished_job": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"completed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"estimated_completion": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"job_result": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"finished_job"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import datetime
|
||||||
|
import random
|
||||||
|
|
||||||
|
import connexion
|
||||||
|
import peewee
|
||||||
|
import six
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from swagger_server.models.job_response import JobResponse # noqa: E501
|
||||||
|
from swagger_server import util
|
||||||
|
from swagger_server.requets_db.models.vrsta import (Job, JobManager)
|
||||||
|
from threading import Semaphore, Thread
|
||||||
|
from swagger_server.classla import cl_utils
|
||||||
|
|
||||||
|
CLASSLA_CONCURANCE_LIMIT = 4
|
||||||
|
classla_sem = asyncio.Semaphore(CLASSLA_CONCURANCE_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def get_job_status(job_id, show_estimated_completion=None): # noqa: E501
|
||||||
|
"""Vrne status
|
||||||
|
|
||||||
|
# noqa: E501
|
||||||
|
|
||||||
|
:param job_id:
|
||||||
|
:type job_id: int
|
||||||
|
:param show_estimated_completion: Calculate estimate time remaining based on various factors (could be inaccurate)
|
||||||
|
:type show_estimated_completion: bool
|
||||||
|
|
||||||
|
:rtype: JobResponse
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job = Job.get_by_id(job_id)
|
||||||
|
if not job.finished_on:
|
||||||
|
est_com = None
|
||||||
|
# todo: if estimate completion: calculate it and set it to est_com
|
||||||
|
return JobResponse(finished_job=False, estimated_completion=est_com), 200
|
||||||
|
return JobResponse(finished_job=True, completed_at=job.finished_on, job_result=job.job_output), 200
|
||||||
|
except peewee.DoesNotExist as e:
|
||||||
|
return "Job with this ID does not exist", 404
|
||||||
|
|
||||||
|
|
||||||
|
def clear_up_unfinished_jobs():
|
||||||
|
"""
|
||||||
|
In case server crashed while jobs were in queue...
|
||||||
|
"""
|
||||||
|
Job.update(started_on=None).where(Job.started_on != None, Job.finished_on == None).execute()
|
||||||
|
|
||||||
|
|
||||||
|
### Job looping
|
||||||
|
async def try_do_jobs():
|
||||||
|
await asyncio.sleep(15) # wait for tokenizers to load for classla ...
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
if cl_utils.nlp_loaded:
|
||||||
|
# print(cl_utils.raw_text_to_conllu("Danes je lep soncen dan. Res je!"))
|
||||||
|
if classla_sem._value > 0:
|
||||||
|
# classla
|
||||||
|
unfinished_jobs = Job.select() \
|
||||||
|
.where(Job.finished_on == None, Job.started_on == None, Job.job_type == 1) \
|
||||||
|
.limit(classla_sem._value)
|
||||||
|
tasks = [
|
||||||
|
asyncio.ensure_future(execute_classla_job(job))
|
||||||
|
for job in unfinished_jobs
|
||||||
|
]
|
||||||
|
asyncio.get_event_loop().create_task(prep_classla_jobs(tasks))
|
||||||
|
# await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Exception in ... {e}")
|
||||||
|
finally:
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
|
||||||
|
async def prep_classla_jobs(tasks):
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_classla_job(job: Job):
|
||||||
|
async with classla_sem:
|
||||||
|
job.started_on = datetime.datetime.utcnow()
|
||||||
|
job.save()
|
||||||
|
conllu, _ = cl_utils.raw_text_to_conllu(job.job_input)
|
||||||
|
job.job_output = conllu
|
||||||
|
job.finished_on = datetime.datetime.utcnow()
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
|
||||||
|
clear_up_unfinished_jobs()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
def loop_in_thread(loop):
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
loop.run_until_complete(try_do_jobs())
|
||||||
|
|
||||||
|
|
||||||
|
t = Thread(target=loop_in_thread, args=(loop,))
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
# clear_up_unfinished_jobs()
|
||||||
|
# loop = asyncio.get_event_loop()
|
||||||
|
# loop.run_until_complete(try_do_jobs()) this version seems more at home, but it blocks the thread, fix that?
|
||||||
@@ -4,6 +4,7 @@ import six
|
|||||||
from swagger_server.models.oznaci_besedilo_body import OznaciBesediloBody # noqa: E501
|
from swagger_server.models.oznaci_besedilo_body import OznaciBesediloBody # noqa: E501
|
||||||
from swagger_server import util
|
from swagger_server import util
|
||||||
from swagger_server.classla import cl_utils
|
from swagger_server.classla import cl_utils
|
||||||
|
from swagger_server.requets_db.models.vrsta import (Job, JobManager)
|
||||||
|
|
||||||
|
|
||||||
def get_text(body): # noqa: E501
|
def get_text(body): # noqa: E501
|
||||||
@@ -18,6 +19,11 @@ def get_text(body): # noqa: E501
|
|||||||
"""
|
"""
|
||||||
if connexion.request.is_json:
|
if connexion.request.is_json:
|
||||||
body = OznaciBesediloBody.from_dict(connexion.request.get_json()) # noqa: E501
|
body = OznaciBesediloBody.from_dict(connexion.request.get_json()) # noqa: E501
|
||||||
conllu = cl_utils.raw_text_to_conllu(body.besedilo)
|
# conllu = cl_utils.raw_text_to_conllu(body.besedilo)
|
||||||
return conllu
|
# return conllu
|
||||||
|
job, is_old_job = JobManager.create_job(1, body.besedilo)
|
||||||
|
if job is None:
|
||||||
|
return "Something went wrong", 500
|
||||||
|
ret = {'check_job_url': f'{connexion.request.url_root}?job_id={job.id}'}
|
||||||
|
|
||||||
|
return ret # Todo: Update swagger to the newest response template later
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ import sys
|
|||||||
|
|
||||||
# todo: DO NOT PUSH THIS TO GIT
|
# todo: DO NOT PUSH THIS TO GIT
|
||||||
database_info = {
|
database_info = {
|
||||||
'database': 'TEMPORARYVALJUSTFORTHECOMMITPURPOSE',
|
'database': 'conllus_150k',
|
||||||
'host': 'TEMPORARYVALJUSTFORTHECOMMITPURPOSE',
|
'host': '164.8.252.72',
|
||||||
'port': 'TEMPORARYVALJUSTFORTHECOMMITPURPOSE',
|
'port': 3306,
|
||||||
'user': 'TEMPORARYVALJUSTFORTHECOMMITPURPOSE',
|
'user': 'kiki',
|
||||||
'password': 'TEMPORARYVALJUSTFORTHECOMMITPURPOSE'
|
'password': 'kiki123'
|
||||||
|
}
|
||||||
|
|
||||||
|
# PUT THIS IN INSTEAD WHEN COMMITING, THIS IS ONLY TEMPORARY UNTIL AN .env FILE IS ADDED
|
||||||
|
database_info_tmp = {
|
||||||
|
'database': '...',
|
||||||
|
'host': '...',
|
||||||
|
'port': 0000,
|
||||||
|
'user': '...',
|
||||||
|
'password': '...'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Connect to MariaDB Platform
|
# Connect to MariaDB Platform
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ from swagger_server.models.datoteka_v_besedilo_ocr_body import DatotekaVBesedilo
|
|||||||
from swagger_server.models.izlusci_body import IzlusciBody
|
from swagger_server.models.izlusci_body import IzlusciBody
|
||||||
from swagger_server.models.oznaci_besedilo_body import OznaciBesediloBody
|
from swagger_server.models.oznaci_besedilo_body import OznaciBesediloBody
|
||||||
from swagger_server.models.terminoloski_kandidat import TerminoloskiKandidat
|
from swagger_server.models.terminoloski_kandidat import TerminoloskiKandidat
|
||||||
|
from swagger_server.models.job_response import JobResponse
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
|
||||||
|
from __future__ import absolute_import
|
||||||
|
from datetime import date, datetime # noqa: F401
|
||||||
|
|
||||||
|
from typing import List, Dict # noqa: F401
|
||||||
|
|
||||||
|
from swagger_server.models.base_model_ import Model
|
||||||
|
from swagger_server import util
|
||||||
|
|
||||||
|
|
||||||
|
class JobResponse(Model):
|
||||||
|
"""NOTE: This class is auto generated by the swagger code generator program.
|
||||||
|
|
||||||
|
Do not edit the class manually.
|
||||||
|
"""
|
||||||
|
def __init__(self, finished_job: bool=None, completed_at: datetime=None, estimated_completion: datetime=None, job_result: str=None): # noqa: E501
|
||||||
|
"""JobResponse - a model defined in Swagger
|
||||||
|
|
||||||
|
:param finished_job: The finished_job of this JobResponse. # noqa: E501
|
||||||
|
:type finished_job: bool
|
||||||
|
:param completed_at: The completed_at of this JobResponse. # noqa: E501
|
||||||
|
:type completed_at: datetime
|
||||||
|
:param estimated_completion: The estimated_completion of this JobResponse. # noqa: E501
|
||||||
|
:type estimated_completion: datetime
|
||||||
|
:param job_result: The job_result of this JobResponse. # noqa: E501
|
||||||
|
:type job_result: str
|
||||||
|
"""
|
||||||
|
self.swagger_types = {
|
||||||
|
'finished_job': bool,
|
||||||
|
'completed_at': datetime,
|
||||||
|
'estimated_completion': datetime,
|
||||||
|
'job_result': str
|
||||||
|
}
|
||||||
|
|
||||||
|
self.attribute_map = {
|
||||||
|
'finished_job': 'finished_job',
|
||||||
|
'completed_at': 'completed_at',
|
||||||
|
'estimated_completion': 'estimated_completion',
|
||||||
|
'job_result': 'job_result'
|
||||||
|
}
|
||||||
|
self._finished_job = finished_job
|
||||||
|
self._completed_at = completed_at
|
||||||
|
self._estimated_completion = estimated_completion
|
||||||
|
self._job_result = job_result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, dikt) -> 'JobResponse':
|
||||||
|
"""Returns the dict as a model
|
||||||
|
|
||||||
|
:param dikt: A dict.
|
||||||
|
:type: dict
|
||||||
|
:return: The JobResponse of this JobResponse. # noqa: E501
|
||||||
|
:rtype: JobResponse
|
||||||
|
"""
|
||||||
|
return util.deserialize_model(dikt, cls)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finished_job(self) -> bool:
|
||||||
|
"""Gets the finished_job of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:return: The finished_job of this JobResponse.
|
||||||
|
:rtype: bool
|
||||||
|
"""
|
||||||
|
return self._finished_job
|
||||||
|
|
||||||
|
@finished_job.setter
|
||||||
|
def finished_job(self, finished_job: bool):
|
||||||
|
"""Sets the finished_job of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:param finished_job: The finished_job of this JobResponse.
|
||||||
|
:type finished_job: bool
|
||||||
|
"""
|
||||||
|
if finished_job is None:
|
||||||
|
raise ValueError("Invalid value for `finished_job`, must not be `None`") # noqa: E501
|
||||||
|
|
||||||
|
self._finished_job = finished_job
|
||||||
|
|
||||||
|
@property
|
||||||
|
def completed_at(self) -> datetime:
|
||||||
|
"""Gets the completed_at of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:return: The completed_at of this JobResponse.
|
||||||
|
:rtype: datetime
|
||||||
|
"""
|
||||||
|
return self._completed_at
|
||||||
|
|
||||||
|
@completed_at.setter
|
||||||
|
def completed_at(self, completed_at: datetime):
|
||||||
|
"""Sets the completed_at of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:param completed_at: The completed_at of this JobResponse.
|
||||||
|
:type completed_at: datetime
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._completed_at = completed_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def estimated_completion(self) -> datetime:
|
||||||
|
"""Gets the estimated_completion of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:return: The estimated_completion of this JobResponse.
|
||||||
|
:rtype: datetime
|
||||||
|
"""
|
||||||
|
return self._estimated_completion
|
||||||
|
|
||||||
|
@estimated_completion.setter
|
||||||
|
def estimated_completion(self, estimated_completion: datetime):
|
||||||
|
"""Sets the estimated_completion of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:param estimated_completion: The estimated_completion of this JobResponse.
|
||||||
|
:type estimated_completion: datetime
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._estimated_completion = estimated_completion
|
||||||
|
|
||||||
|
@property
|
||||||
|
def job_result(self) -> str:
|
||||||
|
"""Gets the job_result of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:return: The job_result of this JobResponse.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
return self._job_result
|
||||||
|
|
||||||
|
@job_result.setter
|
||||||
|
def job_result(self, job_result: str):
|
||||||
|
"""Sets the job_result of this JobResponse.
|
||||||
|
|
||||||
|
|
||||||
|
:param job_result: The job_result of this JobResponse.
|
||||||
|
:type job_result: str
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._job_result = job_result
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from peewee import *
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not os.path.exists('dbs'):
|
||||||
|
os.mkdir('dbs')
|
||||||
|
|
||||||
|
DB = 'requets_db/dbs/jobs.db'
|
||||||
|
db = SqliteDatabase(DB, pragmas={
|
||||||
|
# 'journal_mode': 'wal',
|
||||||
|
'cache_size': -1 * 128 * 1024, # 128MB
|
||||||
|
'foreign_keys': 1})
|
||||||
|
|
||||||
|
|
||||||
|
class BaseModel(Model):
|
||||||
|
class Meta:
|
||||||
|
database = db
|
||||||
|
|
||||||
|
|
||||||
|
class Job(BaseModel):
|
||||||
|
id = AutoField()
|
||||||
|
job_type = IntegerField() # 1 = oznaci besedilo
|
||||||
|
job_input = TextField(index=True)
|
||||||
|
job_output = TextField(null=True)
|
||||||
|
created_on = DateTimeField(default=datetime.utcnow)
|
||||||
|
finished_on = DateTimeField(null=True)
|
||||||
|
input_size = IntegerField()
|
||||||
|
started_on = DateTimeField(null=True)
|
||||||
|
|
||||||
|
|
||||||
|
# db.drop_tables([Job])
|
||||||
|
db.create_tables([Job])
|
||||||
|
|
||||||
|
|
||||||
|
class JobManager:
|
||||||
|
@staticmethod
|
||||||
|
def create_job(job_type, job_input) -> Tuple(Job, bool):
|
||||||
|
"""
|
||||||
|
:return: Job object, Did already exist boolean
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
job, is_new = Job.get_or_create(job_type=job_type, job_input=job_input, input_size=len(job_input))
|
||||||
|
return job, not is_new
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Exception at creating a job: {e}')
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
# try:
|
||||||
|
# job = Job.get_or_none(Job.job_input == job_input, Job.job_type == job_type)
|
||||||
|
# if job:
|
||||||
|
# return job, True
|
||||||
|
# job = Job.create(job_type=job_type, job_input=job_input, input_size=len(job_input))
|
||||||
|
# return job, False
|
||||||
|
# except Exception as e:
|
||||||
|
# print(f'Exception at creating a job: {e}')
|
||||||
|
# return None, False
|
||||||
@@ -6,6 +6,38 @@ servers:
|
|||||||
- url: http://localhost:8089
|
- url: http://localhost:8089
|
||||||
description: Generated server url
|
description: Generated server url
|
||||||
paths:
|
paths:
|
||||||
|
/job/{job_id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- jobs
|
||||||
|
summary: Vrne status
|
||||||
|
operationId: get_job_status
|
||||||
|
parameters:
|
||||||
|
- name: job_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
style: simple
|
||||||
|
explode: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
- name: show_estimated_completion
|
||||||
|
in: query
|
||||||
|
description: Calculate estimate time remaining based on various factors (could
|
||||||
|
be inaccurate)
|
||||||
|
required: false
|
||||||
|
style: form
|
||||||
|
explode: true
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/JobResponse'
|
||||||
|
x-openapi-router-controller: swagger_server.controllers.jobs_controller
|
||||||
/oznaciBesedilo:
|
/oznaciBesedilo:
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
@@ -485,6 +517,26 @@ components:
|
|||||||
kanonicnaoblika: kanonicnaoblika
|
kanonicnaoblika: kanonicnaoblika
|
||||||
nosilnautez: 0.8008282
|
nosilnautez: 0.8008282
|
||||||
kandidat: kandidat
|
kandidat: kandidat
|
||||||
|
JobResponse:
|
||||||
|
required:
|
||||||
|
- finished_job
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
finished_job:
|
||||||
|
type: boolean
|
||||||
|
completed_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
estimated_completion:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
job_result:
|
||||||
|
type: string
|
||||||
|
example:
|
||||||
|
completed_at: 2000-01-23T04:56:07.000+00:00
|
||||||
|
estimated_completion: 2000-01-23T04:56:07.000+00:00
|
||||||
|
job_result: job_result
|
||||||
|
finished_job: true
|
||||||
oznaciBesedilo_body:
|
oznaciBesedilo_body:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
|
||||||
|
from __future__ import absolute_import
|
||||||
|
|
||||||
|
from flask import json
|
||||||
|
from six import BytesIO
|
||||||
|
|
||||||
|
from swagger_server.models.job_response import JobResponse # noqa: E501
|
||||||
|
from swagger_server.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobsController(BaseTestCase):
|
||||||
|
"""JobsController integration test stubs"""
|
||||||
|
|
||||||
|
def test_get_job_status(self):
|
||||||
|
"""Test case for get_job_status
|
||||||
|
|
||||||
|
Vrne status
|
||||||
|
"""
|
||||||
|
query_string = [('job_id', 789),
|
||||||
|
('show_estimated_completion', True)]
|
||||||
|
response = self.client.open(
|
||||||
|
'/job',
|
||||||
|
method='GET',
|
||||||
|
query_string=query_string)
|
||||||
|
self.assert200(response,
|
||||||
|
'Response body is : ' + response.data.decode('utf-8'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import unittest
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user