feat: add UpdateVersionAction
This commit is contained in:
parent
152546982c
commit
839f67ad0a
8 changed files with 260 additions and 139 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -88,7 +88,6 @@ celerybeat-schedule
|
||||||
celerybeat.pid
|
celerybeat.pid
|
||||||
*.sage.py
|
*.sage.py
|
||||||
.env
|
.env
|
||||||
.venv
|
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
ENV/
|
ENV/
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,6 @@
|
||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: huesoporro
|
appVersion: 0.3.0
|
||||||
description: A Helm chart for Kubernetes
|
description: A Helm chart for Kubernetes
|
||||||
|
name: huesoporro
|
||||||
# A chart can be either an 'application' or a 'library' chart.
|
|
||||||
#
|
|
||||||
# Application charts are a collection of templates that can be packaged into versioned archives
|
|
||||||
# to be deployed.
|
|
||||||
#
|
|
||||||
# Library charts provide useful utilities or functions for the chart developer. They're included as
|
|
||||||
# a dependency of application charts to inject those utilities and functions into the rendering
|
|
||||||
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
|
|
||||||
type: application
|
type: application
|
||||||
|
|
||||||
# This is the chart version. This version number should be incremented each time you make changes
|
|
||||||
# to the chart and its templates, including the app version.
|
|
||||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
|
||||||
version: 0.3.0
|
version: 0.3.0
|
||||||
|
|
||||||
# This is the version number of the application being deployed. This version number should be
|
|
||||||
# incremented each time you make changes to the application. Versions are not expected to
|
|
||||||
# follow Semantic Versioning. They should reflect the version the application is using.
|
|
||||||
# It is recommended to use it with quotes.
|
|
||||||
appVersion: "0.3.0"
|
|
||||||
|
|
|
||||||
|
|
@ -1,138 +1,62 @@
|
||||||
# Default values for helm.
|
affinity: {}
|
||||||
# This is a YAML-formatted file.
|
autoscaling:
|
||||||
# Declare variables to be passed into your templates.
|
|
||||||
|
|
||||||
# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/
|
|
||||||
replicaCount: 1
|
|
||||||
|
|
||||||
# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/
|
|
||||||
image:
|
|
||||||
repository: git.roboces.dev/catalin/huesoporro
|
|
||||||
# This sets the pull policy for images.
|
|
||||||
pullPolicy: Always
|
|
||||||
# Overrides the image tag whose default is the chart appVersion.
|
|
||||||
tag: "0.3.0"
|
|
||||||
|
|
||||||
# This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/
|
|
||||||
imagePullSecrets: []
|
|
||||||
# This is to override the chart name.
|
|
||||||
nameOverride: ""
|
|
||||||
fullnameOverride: ""
|
|
||||||
|
|
||||||
#This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/
|
|
||||||
serviceAccount:
|
|
||||||
# Specifies whether a service account should be created
|
|
||||||
create: true
|
|
||||||
# Automatically mount a ServiceAccount's API credentials?
|
|
||||||
automount: true
|
|
||||||
# Annotations to add to the service account
|
|
||||||
annotations: {}
|
|
||||||
# The name of the service account to use.
|
|
||||||
# If not set and create is true, a name is generated using the fullname template
|
|
||||||
name: ""
|
|
||||||
|
|
||||||
# This is for setting Kubernetes Annotations to a Pod.
|
|
||||||
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/
|
|
||||||
podAnnotations: {}
|
|
||||||
# This is for setting Kubernetes Labels to a Pod.
|
|
||||||
# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/
|
|
||||||
podLabels: {}
|
|
||||||
|
|
||||||
podSecurityContext: {}
|
|
||||||
# fsGroup: 2000
|
|
||||||
|
|
||||||
securityContext: {}
|
|
||||||
# capabilities:
|
|
||||||
# drop:
|
|
||||||
# - ALL
|
|
||||||
# readOnlyRootFilesystem: true
|
|
||||||
# runAsNonRoot: true
|
|
||||||
# runAsUser: 1000
|
|
||||||
|
|
||||||
# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/
|
|
||||||
service:
|
|
||||||
# This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types
|
|
||||||
type: LoadBalancer
|
|
||||||
# This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
|
|
||||||
port: 8000
|
|
||||||
|
|
||||||
# This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
|
|
||||||
ingress:
|
|
||||||
enabled: false
|
enabled: false
|
||||||
className: ""
|
maxReplicas: 100
|
||||||
|
minReplicas: 1
|
||||||
|
targetCPUUtilizationPercentage: 80
|
||||||
|
fullnameOverride: ''
|
||||||
|
image:
|
||||||
|
pullPolicy: Always
|
||||||
|
repository: git.roboces.dev/catalin/huesoporro
|
||||||
|
tag: 0.3.0
|
||||||
|
imagePullSecrets: []
|
||||||
|
ingress:
|
||||||
annotations: {}
|
annotations: {}
|
||||||
# kubernetes.io/ingress.class: nginx
|
className: ''
|
||||||
# kubernetes.io/tls-acme: "true"
|
enabled: false
|
||||||
hosts:
|
hosts:
|
||||||
- host: chart-example.local
|
- host: chart-example.local
|
||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: ImplementationSpecific
|
pathType: ImplementationSpecific
|
||||||
tls: []
|
tls: []
|
||||||
# - secretName: chart-example-tls
|
|
||||||
# hosts:
|
|
||||||
# - chart-example.local
|
|
||||||
|
|
||||||
resources: {}
|
|
||||||
# We usually recommend not to specify default resources and to leave this as a conscious
|
|
||||||
# choice for the user. This also increases chances charts run on environments with little
|
|
||||||
# resources, such as Minikube. If you do want to specify resources, uncomment the following
|
|
||||||
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
|
|
||||||
# limits:
|
|
||||||
# cpu: 100m
|
|
||||||
# memory: 128Mi
|
|
||||||
# requests:
|
|
||||||
# cpu: 100m
|
|
||||||
# memory: 128Mi
|
|
||||||
|
|
||||||
# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
|
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
port: http
|
port: http
|
||||||
|
nameOverride: ''
|
||||||
|
nodeSelector: {}
|
||||||
|
persistence:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteMany
|
||||||
|
annotations: {}
|
||||||
|
enabled: false
|
||||||
|
size: 10Gi
|
||||||
|
storageClassName: default
|
||||||
|
volumeOwner:
|
||||||
|
enabled: true
|
||||||
|
gid: 1000
|
||||||
|
uid: 1000
|
||||||
|
podAnnotations: {}
|
||||||
|
podLabels: {}
|
||||||
|
podSecurityContext: {}
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /healthz
|
path: /healthz
|
||||||
port: http
|
port: http
|
||||||
|
replicaCount: 1
|
||||||
#This section is for setting up autoscaling more information can be found here: https://kubernetes.io/docs/concepts/workloads/autoscaling/
|
resources: {}
|
||||||
autoscaling:
|
|
||||||
enabled: false
|
|
||||||
minReplicas: 1
|
|
||||||
maxReplicas: 100
|
|
||||||
targetCPUUtilizationPercentage: 80
|
|
||||||
# targetMemoryUtilizationPercentage: 80
|
|
||||||
|
|
||||||
# Additional volumes on the output Deployment definition.
|
|
||||||
volumes: []
|
|
||||||
# - name: foo
|
|
||||||
# secret:
|
|
||||||
# secretName: mysecret
|
|
||||||
# optional: false
|
|
||||||
|
|
||||||
# Additional volumeMounts on the output Deployment definition.
|
|
||||||
volumeMounts: []
|
|
||||||
# - name: foo
|
|
||||||
# mountPath: "/etc/foo"
|
|
||||||
# readOnly: true
|
|
||||||
|
|
||||||
nodeSelector: {}
|
|
||||||
|
|
||||||
tolerations: []
|
|
||||||
|
|
||||||
affinity: {}
|
|
||||||
|
|
||||||
persistence:
|
|
||||||
enabled: false
|
|
||||||
accessModes:
|
|
||||||
- ReadWriteMany
|
|
||||||
size: 10Gi
|
|
||||||
storageClassName: "default"
|
|
||||||
volumeOwner:
|
|
||||||
enabled: true
|
|
||||||
uid: 1000
|
|
||||||
gid: 1000
|
|
||||||
annotations: {}
|
|
||||||
|
|
||||||
secret:
|
secret:
|
||||||
existingSecretName: huesoporro-secrets
|
existingSecretName: huesoporro-secrets
|
||||||
|
securityContext: {}
|
||||||
|
service:
|
||||||
|
port: 8000
|
||||||
|
type: LoadBalancer
|
||||||
|
serviceAccount:
|
||||||
|
annotations: {}
|
||||||
|
automount: true
|
||||||
|
create: true
|
||||||
|
name: ''
|
||||||
|
tolerations: []
|
||||||
|
volumeMounts: []
|
||||||
|
volumes: []
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ dev-dependencies = [
|
||||||
"ruff>=0.8.3",
|
"ruff>=0.8.3",
|
||||||
"pytest-coverage>=0.0",
|
"pytest-coverage>=0.0",
|
||||||
"polyfactory>=2.18.1",
|
"polyfactory>=2.18.1",
|
||||||
|
"types-pyyaml>=6.0.12.20241230",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from loguru import logger
|
||||||
from typer import Typer
|
from typer import Typer
|
||||||
|
|
||||||
from huesoporro.actions.import_from_vod import ImportFromVODAction
|
from huesoporro.actions.import_from_vod import ImportFromVODAction
|
||||||
|
from huesoporro.actions.misc.update_version_action import UpdateVersionAction
|
||||||
from huesoporro.settings import Settings
|
from huesoporro.settings import Settings
|
||||||
from huesoporro.svc.clean_cc_svc import CleanCCSvc
|
from huesoporro.svc.clean_cc_svc import CleanCCSvc
|
||||||
from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc
|
from huesoporro.svc.download_closed_captions import DownloadClosedCaptionsSvc
|
||||||
|
|
@ -22,3 +23,8 @@ def import_vod_cc(channel_name: str, youtube_url: str, db_path: Path | None = No
|
||||||
)
|
)
|
||||||
for cc_filepath in import_from_vod_action.run(channel_name, youtube_url):
|
for cc_filepath in import_from_vod_action.run(channel_name, youtube_url):
|
||||||
logger.info(f"Closed captions imported from {cc_filepath}")
|
logger.info(f"Closed captions imported from {cc_filepath}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def update_version(version: str, dry_run: bool = False):
|
||||||
|
UpdateVersionAction().run(version, dry_run)
|
||||||
|
|
|
||||||
0
src/huesoporro/actions/misc/__init__.py
Normal file
0
src/huesoporro/actions/misc/__init__.py
Normal file
198
src/huesoporro/actions/misc/update_version_action.py
Normal file
198
src/huesoporro/actions/misc/update_version_action.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from difflib import unified_diff
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from rich import print # noqa: A004
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.panel import Panel
|
||||||
|
from rich.syntax import Syntax
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateVersionAction(BaseModel):
|
||||||
|
project_root: Path = Path(__file__).parents[4]
|
||||||
|
files_to_update: dict[str, Callable]
|
||||||
|
console: Console = Console()
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
def __init__(self, **data):
|
||||||
|
files_to_update = {
|
||||||
|
"pyproject.toml": self._update_pyproject_toml,
|
||||||
|
"charts/huesoporro/values.yaml": self._update_values_yaml,
|
||||||
|
"charts/huesoporro/Chart.yaml": self._update_chart_yaml,
|
||||||
|
}
|
||||||
|
super().__init__(**data, files_to_update=files_to_update)
|
||||||
|
|
||||||
|
def _read_file(self, filepath: Path) -> str:
|
||||||
|
"""
|
||||||
|
Read the contents of a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath (Path): Path to the file to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: File contents
|
||||||
|
"""
|
||||||
|
with filepath.open("r") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def _write_file(self, filepath: Path, content: str):
|
||||||
|
"""
|
||||||
|
Write content to a file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath (Path): Path to the file to write.
|
||||||
|
content (str): Content to write to the file.
|
||||||
|
"""
|
||||||
|
with filepath.open("w") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
def _update_pyproject_toml(self, filepath: Path, new_version: str) -> str:
|
||||||
|
"""
|
||||||
|
Update version in pyproject.toml.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath (Path): Path to pyproject.toml
|
||||||
|
new_version (str): New version to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Updated file content
|
||||||
|
"""
|
||||||
|
content = self._read_file(filepath)
|
||||||
|
version_pattern = r'(version\s*=\s*)[\'"](.+?)[\'"]'
|
||||||
|
return re.sub(version_pattern, rf'\1"{new_version}"', content)
|
||||||
|
|
||||||
|
def _update_values_yaml(self, filepath: Path, new_version: str) -> str:
|
||||||
|
"""
|
||||||
|
Update image tag in values.yaml.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath (Path): Path to values.yaml
|
||||||
|
new_version (str): New version to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Updated file content
|
||||||
|
"""
|
||||||
|
with filepath.open("r") as file:
|
||||||
|
values = yaml.safe_load(file)
|
||||||
|
|
||||||
|
# Assumes image.tag exists in the values.yaml
|
||||||
|
values["image"]["tag"] = new_version
|
||||||
|
|
||||||
|
return yaml.dump(values, default_flow_style=False)
|
||||||
|
|
||||||
|
def _update_chart_yaml(self, filepath: Path, new_version: str) -> str:
|
||||||
|
"""
|
||||||
|
Update version and appVersion in Chart.yaml.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath (Path): Path to Chart.yaml
|
||||||
|
new_version (str): New version to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Updated file content
|
||||||
|
"""
|
||||||
|
with filepath.open("r") as file:
|
||||||
|
chart_data = yaml.safe_load(file)
|
||||||
|
|
||||||
|
chart_data["version"] = new_version
|
||||||
|
chart_data["appVersion"] = new_version
|
||||||
|
|
||||||
|
return yaml.dump(chart_data, default_flow_style=False)
|
||||||
|
|
||||||
|
def _generate_diff(self, original: str, updated: str, filename: str) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unified diff between original and updated content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original (str): Original file content
|
||||||
|
updated (str): Updated file content
|
||||||
|
filename (str): Name of the file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Unified diff representation
|
||||||
|
"""
|
||||||
|
# Split content into lines
|
||||||
|
original_lines = original.splitlines(keepends=True)
|
||||||
|
updated_lines = updated.splitlines(keepends=True)
|
||||||
|
|
||||||
|
# Generate unified diff
|
||||||
|
diff_lines = list(
|
||||||
|
unified_diff(
|
||||||
|
original_lines,
|
||||||
|
updated_lines,
|
||||||
|
fromfile=f"a/{filename}",
|
||||||
|
tofile=f"b/{filename}",
|
||||||
|
lineterm="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(diff_lines)
|
||||||
|
|
||||||
|
def _rich_display_diff(self, diff: str):
|
||||||
|
"""
|
||||||
|
Display diff using rich for colorful output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff (str): Unified diff to display
|
||||||
|
"""
|
||||||
|
if not diff:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use Syntax for syntax highlighting
|
||||||
|
syntax = Syntax(diff, "diff", theme="ansi_dark")
|
||||||
|
|
||||||
|
# Create a panel with the diff
|
||||||
|
panel = Panel(
|
||||||
|
syntax, title="Version Update Diff", border_style="cyan", expand=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display the panel
|
||||||
|
self.console.print(panel)
|
||||||
|
|
||||||
|
def run(self, new_version: str, dry_run: bool = False):
|
||||||
|
"""
|
||||||
|
Update version across all specified files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
new_version (str): New version to set
|
||||||
|
dry_run (bool): Dry run mode with diff display
|
||||||
|
"""
|
||||||
|
for relative_path, update_func in self.files_to_update.items():
|
||||||
|
filepath = self.project_root / relative_path
|
||||||
|
|
||||||
|
if not filepath.exists():
|
||||||
|
logger.warning(f"Warning: {filepath} not found. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read original content
|
||||||
|
original_content = self._read_file(filepath)
|
||||||
|
|
||||||
|
# Generate updated content
|
||||||
|
updated_content = update_func(filepath, new_version)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
# Generate and display diff
|
||||||
|
diff = self._generate_diff(
|
||||||
|
original_content, updated_content, str(relative_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display the diff
|
||||||
|
if diff:
|
||||||
|
print(f"\nDiff for {relative_path}:")
|
||||||
|
self._rich_display_diff(diff)
|
||||||
|
else:
|
||||||
|
# Write updated content
|
||||||
|
self._write_file(filepath, updated_content)
|
||||||
|
print(f"Updated {relative_path}")
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.error(f"Error updating {relative_path}: {exc}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
print("\nDry run complete. No files were modified.")
|
||||||
11
uv.lock
generated
11
uv.lock
generated
|
|
@ -549,6 +549,7 @@ dev = [
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-coverage" },
|
{ name = "pytest-coverage" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "types-pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
|
@ -582,6 +583,7 @@ dev = [
|
||||||
{ name = "pytest-asyncio", specifier = ">=0.25.0" },
|
{ name = "pytest-asyncio", specifier = ">=0.25.0" },
|
||||||
{ name = "pytest-coverage", specifier = ">=0.0" },
|
{ name = "pytest-coverage", specifier = ">=0.0" },
|
||||||
{ name = "ruff", specifier = ">=0.8.3" },
|
{ name = "ruff", specifier = ">=0.8.3" },
|
||||||
|
{ name = "types-pyyaml", specifier = ">=6.0.12.20241230" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1482,6 +1484,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
|
{ url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-pyyaml"
|
||||||
|
version = "6.0.12.20241230"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/f9/4d566925bcf9396136c0a2e5dc7e230ff08d86fa011a69888dd184469d80/types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c", size = 17078 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/c1/48474fbead512b70ccdb4f81ba5eb4a58f69d100ba19f17c92c0c4f50ae6/types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6", size = 20029 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.12.2"
|
version = "4.12.2"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue