Skip to content

Commit f517576

Browse files
authored
feat: improve editionctl script in various ways (TencentBlueKing#214)
1 parent 34ee942 commit f517576

4 files changed

Lines changed: 153 additions & 107 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "BlueKing PaaS Python SDK"
55
authors = ["blueking <blueking@tencent.com>"]
66

77
[tool.poetry.dependencies]
8-
python = ">=3.8.1,<3.9"
8+
python = ">=3.8.1,<3.12"
99

1010
[tool.poetry.group.dev.dependencies]
1111
ruff = "^0.1.7"

sdks/blue-krill/blue_krill/data_types/enum.py

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
# -*- coding: utf-8 -*-
2-
"""
3-
* TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-蓝鲸 PaaS 平台(BlueKing-PaaS) available.
4-
* Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
5-
* Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
6-
* You may obtain a copy of the License at http://opensource.org/licenses/MIT
7-
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
8-
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
9-
* specific language governing permissions and limitations under the License.
10-
"""
2+
# TencentBlueKing is pleased to support the open source community by making
3+
# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
4+
# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
5+
# Licensed under the MIT License (the "License"); you may not use this file except
6+
# in compliance with the License. You may obtain a copy of the License at
7+
#
8+
# http://opensource.org/licenses/MIT
9+
#
10+
# Unless required by applicable law or agreed to in writing, software distributed under
11+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
12+
# either express or implied. See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# We undertake not to change the open source license (MIT license) applicable
16+
# to the current version of the project delivered to anyone in the future.
1117
import dataclasses
1218
from collections import OrderedDict
1319
from enum import Enum as OrigEnum
@@ -54,7 +60,7 @@ def __str__(self):
5460
class FeatureFlagMeta(type):
5561
_feature_flag_fields_: Dict[str, FeatureFlagField]
5662

57-
def __new__(mcs, cls_name: str, bases, dct: Dict):
63+
def __new__(mcs, cls_name: str, bases, dct: Dict): # noqa: N804
5864
_feature_flag_fields_ = {}
5965
for base in bases:
6066
_feature_flag_fields_.update(getattr(base, "_feature_flag_fields_", {}))
@@ -156,7 +162,7 @@ class StructuredEnumMeta(EnumMeta):
156162

157163
__field_members__: Dict[Type, Dict]
158164

159-
def __new__(metacls, cls, bases, classdict):
165+
def __new__(metacls, cls, bases, classdict): # noqa: N804
160166
field_members = metacls.process_enum_fields(classdict)
161167
classdict["__field_members__"] = field_members
162168
return super().__new__(metacls, cls, bases, classdict)
@@ -170,14 +176,16 @@ def process_enum_fields(classdict) -> Dict:
170176
"""
171177
fields = OrderedDict()
172178
# Find out all `EnumField` instance, store them into class so we can use them later
173-
for key, member in classdict.items():
179+
for key, orig_member in classdict.items():
174180
# Ignore all private members
175181
if key.startswith("_"):
176182
continue
177183

178184
# Turn regular enum member into EnumField instance
179-
if not isinstance(member, EnumField) and isinstance(member, (int, str, auto)):
180-
member = EnumField(member)
185+
if not isinstance(orig_member, EnumField) and isinstance(orig_member, (int, str, auto)):
186+
member = EnumField(orig_member)
187+
else:
188+
member = orig_member
181189
if not isinstance(member, EnumField) or member.is_reserved:
182190
continue
183191

@@ -240,22 +248,21 @@ def get_choices(cls) -> List[Tuple[Any, str]]:
240248

241249
try:
242250
# python 3.11+ required
243-
from enum import StrEnum, IntEnum
251+
from enum import IntEnum, StrEnum # type: ignore
244252
except ImportError:
245253
pass
246254
else:
255+
247256
class StrStructuredEnum(StructuredEnum, StrEnum):
248257
"""
249258
StrStructuredEnum ensures the literals in f-string / str.format() is real_value
250259
251260
Important: Use XEnum(StrStructuredEnum) instead of XEnum(str, StructuredEnum) since python 3.11
252261
"""
253-
pass
254262

255263
class IntStructuredEnum(StructuredEnum, IntEnum):
256264
"""
257265
IntStructuredEnum ensures the literals in f-string / str.format() is real_value
258266
259267
Important: Use XEnum(IntStructuredEnum) instead of XEnum(int, StructuredEnum) since python 3.11
260268
"""
261-
pass

sdks/blue-krill/blue_krill/editions/editionctl.py

Lines changed: 64 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
# -*- coding: utf-8 -*-
2-
"""
3-
* TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-蓝鲸 PaaS 平台(BlueKing-PaaS) available.
4-
* Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved.
5-
* Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License.
6-
* You may obtain a copy of the License at http://opensource.org/licenses/MIT
7-
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
8-
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
9-
* specific language governing permissions and limitations under the License.
10-
"""
2+
# TencentBlueKing is pleased to support the open source community by making
3+
# 蓝鲸智云 - PaaS 平台 (BlueKing - PaaS System) available.
4+
# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved.
5+
# Licensed under the MIT License (the "License"); you may not use this file except
6+
# in compliance with the License. You may obtain a copy of the License at
7+
#
8+
# http://opensource.org/licenses/MIT
9+
#
10+
# Unless required by applicable law or agreed to in writing, software distributed under
11+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
12+
# either express or implied. See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
# We undertake not to change the open source license (MIT license) applicable
16+
# to the current version of the project delivered to anyone in the future.
1117
import abc
1218
import fnmatch
1319
import json
@@ -16,6 +22,7 @@
1622
import shutil
1723
import stat
1824
import sys
25+
import threading
1926
import time
2027
from dataclasses import dataclass, field
2128
from os import PathLike
@@ -56,7 +63,8 @@ class CopyLinker(FileLinker):
5663
"""Directly copy"""
5764

5865
def link(self, src_file, dst_file):
59-
shutil.copyfile(src_file, dst_file, follow_symlinks=False)
66+
# Use copy2 to copy file stats such as mtime
67+
shutil.copy2(src_file, dst_file, follow_symlinks=False)
6068

6169
def unlink(self, dst_file):
6270
Path(dst_file).unlink()
@@ -76,7 +84,8 @@ def set_file_permission(self, path: Path, mode):
7684
def link(self, src_file, dst_file):
7785
# make sure file is writable before copy
7886
self.set_file_permission(dst_file, self.writable_mode)
79-
shutil.copyfile(src_file, dst_file, follow_symlinks=False)
87+
# Use copy2 to copy file stats such as mtime
88+
shutil.copy2(src_file, dst_file, follow_symlinks=False)
8089
# make sure file is read-only to avoid unexpected modifies
8190
self.set_file_permission(dst_file, self.read_only_mode)
8291

@@ -221,7 +230,7 @@ def activate(ctx, edition_name_in_pos, edition_name, linker_type):
221230
we found that solution is too difficult and fragile to implement because we need our namespace
222231
packages to be nested.
223232
224-
So currently we are using a simpler(and stupidier) approach: sync all source files under the
233+
So currently we are using a simpler(and stupider) approach: sync all source files under the
225234
specified edition directory to the main package.
226235
"""
227236
settings = {}
@@ -233,11 +242,11 @@ def activate(ctx, edition_name_in_pos, edition_name, linker_type):
233242

234243
edition_name = edition_name_in_pos or edition_name
235244
if not edition_name:
236-
logger.critical('Must provide edtion name via position argument or "--edition-name"')
245+
logger.critical('Must provide edition name via position argument or "--edition-name"')
237246
sys.exit(1)
238247

239248
try:
240-
migrator = EditionFileMigrater(config, edition_name)
249+
migrator = EditionFileMigrator(config, edition_name)
241250
migrator.migrate()
242251
except RuntimeError:
243252
logger.exception("unable to finish the migration")
@@ -348,8 +357,8 @@ def list_managed_files(self) -> Iterator[Path]:
348357
yield self.metadata_path
349358

350359

351-
class EditionFileMigrater:
352-
"""The file migrater"""
360+
class EditionFileMigrator:
361+
"""The file migrator"""
353362

354363
def __init__(self, config: Configuration, edition_name: str):
355364
self.config = config
@@ -395,9 +404,7 @@ def should_reset(self, last_metadata: EditionMetaData):
395404
"""If the migrator should reset all migrated states"""
396405
if last_metadata.edition_name != self.edition_name:
397406
return True
398-
if last_metadata.linker_type != self.config.get_linker_type():
399-
return True
400-
return False
407+
return last_metadata.linker_type != self.config.get_linker_type()
401408

402409

403410
@main.command()
@@ -412,7 +419,7 @@ def reset_project(config: Configuration):
412419
"""Reset all migration states.
413420
414421
- Remove metadata and related files
415-
- Unlink all migarated files(from metadata file)
422+
- Unlink all migrated files(from metadata file)
416423
"""
417424
metadata = load_current_metadata(config)
418425
if metadata is None:
@@ -421,7 +428,7 @@ def reset_project(config: Configuration):
421428

422429
logger.info("Resetting project.")
423430
for filepath in metadata.list_managed_files():
424-
logger.debug(f"Unlinking file {filepath}.")
431+
logger.info(f"Unlinking file {filepath}.")
425432
try:
426433
filepath.unlink()
427434
except FileNotFoundError:
@@ -448,7 +455,7 @@ class SyncResult:
448455
class DirectorySyncer:
449456
"""A simple directory syncer"""
450457

451-
ignore_patterns = ("*.pyc", "*.pyo", "CVS", "tmp", ".git", ".svn", "__pycache__")
458+
ignore_patterns = ("*.pyc", "*.pyo", "CVS", "tmp", ".git", ".svn", "__pycache__", ".mypy_cache")
452459

453460
def __init__(self, file_linker: FileLinker):
454461
self.file_linker = file_linker
@@ -491,8 +498,11 @@ def sync_files(
491498
dst_file = dst_path / filename
492499

493500
rel_file = Path(rel_path) / filename
494-
logger.debug(f"Linking file {rel_file}...")
495-
self.file_linker.link(src_file, dst_file)
501+
502+
if not self.are_files_identical(src_file, dst_file):
503+
logger.info(f"Linking file {rel_file}...")
504+
self.file_linker.link(src_file, dst_file)
505+
496506
result.added_files.add(dst_file)
497507
result.added_files_relative.add(rel_file)
498508

@@ -510,6 +520,15 @@ def sync_files(
510520
result.deleted_files.add(file_path)
511521
return result
512522

523+
@staticmethod
524+
def are_files_identical(src_file, dst_file):
525+
"""Check if two files are identical by comparing size and mtime."""
526+
if not (src_file.exists() and dst_file.exists()):
527+
return False
528+
src_stat = src_file.stat()
529+
dst_stat = dst_file.stat()
530+
return src_stat.st_size == dst_stat.st_size and src_stat.st_mtime == dst_stat.st_mtime
531+
513532

514533
@main.command()
515534
@click.pass_context
@@ -562,7 +581,7 @@ def help(ctx):
562581
@click.pass_context
563582
def develop(ctx):
564583
"""Enter develop mode, auto trigger edition activate procedure after files under current
565-
edtion directory have been modified.
584+
edition directory have been modified.
566585
"""
567586
config = get_configuration_or_quit(ctx.obj["settings_path"])
568587
project_root = config.get_project_root()
@@ -587,25 +606,38 @@ def develop(ctx):
587606

588607

589608
class EditionDevelopEventHandler(FileSystemEventHandler):
590-
"""Handler will auto re-activate edition when it's content changes"""
609+
"""Handler will auto re-activate edition when its content changes"""
610+
611+
debounce_delay = 0.5
591612

592613
def __init__(self, config: Configuration, edition_name: str):
593614
self.config = config
594615
self.edition_name = edition_name
595616
super().__init__()
596617

597-
def on_any_event(self, event):
598-
logger.debug(f"Event {event} detected, will re-activate project edtion")
618+
# A debounce time to avoid unnecessary reactivation
619+
self._debounce_timer: Optional[threading.Timer] = None
599620

600-
logger.info("Going to re-activate edtion...")
601-
migrator = EditionFileMigrater(self.config, self.edition_name)
621+
def _reactivate(self):
622+
logger.debug("Begin reactivate edition...")
623+
migrator = EditionFileMigrator(self.config, self.edition_name)
602624
try:
603625
migrator.migrate()
604626
except RuntimeError:
605627
logger.critical("unable to finish the migration")
606-
logger.info(f"Edition {self.edition_name} re-activated, linker is {self.config.get_linker_type()}")
628+
logger.info(f"Edition {self.edition_name} reactivated, linker: {self.config.get_linker_type()}")
629+
630+
def on_any_event(self, event):
631+
# Ignore events that are not relevant
632+
if event.event_type not in {"modified", "created", "deleted"}:
633+
return
634+
635+
logger.debug(f"Event {event} detected, scheduling debounce reactivation")
636+
if self._debounce_timer:
637+
self._debounce_timer.cancel()
607638

608-
logger.info("Inform project dev server to reload.")
639+
self._debounce_timer = threading.Timer(self.debounce_delay, self._reactivate)
640+
self._debounce_timer.start()
609641

610642

611643
if __name__ == "__main__":

0 commit comments

Comments
 (0)