Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 1개 있습니다.)

Visual Studio 확장(VSIX)을 이용해 사용자 메뉴 추가하는 방법 (2) - 동적 메뉴 구성

지난 글에서 메뉴 추가 방법에 대한 기본적인 내용을 살펴봤습니다.

Visual Studio 확장(VSIX)을 이용해 사용자 메뉴 추가하는 방법
; https://www.sysnet.pe.kr/2/0/11184

그런데, 때로는 메뉴를 동적으로 구성해야 할 때가 있습니다. Visual Studio의 경우 대표적인 사례로 "File" / "Recent Files" 하위 메뉴를 들 수 있는데, 이를 위한 방법 역시 문서화가 너무 잘되어 있습니다. ^^

Dynamically Adding Menu Items
; https://docs.microsoft.com/en-us/previous-versions/visualstudio/visual-studio-2015/extensibility/dynamically-adding-menu-items

위의 글에 따라, 동적으로 추가되는 "매크로 명령어 목록"을 구성해 보겠습니다.

우선, 동적 메뉴로 펼쳐질 서브 메뉴의 부모가 되어줄 Menu가 있어야 하는데, 그 메뉴는 또한 Command 그룹에 속해 있어야 합니다. 기본적으로 .vsct 파일에는 ("Extensibility" / "Custom Command"를 추가한 경우) 한 개의 그룹이 등록되어 있으므로,

<Groups>
    <Group guid="guidMacroCommandPackageCmdSet" id="MyMenuGroup" priority="0x0600">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_MENU_TOOLS"/>
    </Group>
</Groups>

위의 "MyMenuGroup"과 연결될 메뉴만 하나 추가해 주면 됩니다.

<Menus>
    <Menu guid="guidMacroCommandPackageCmdSet" id="MyMenuController" priority="0x1000" type="MenuController">
	<Parent guid="guidMacroCommandPackageCmdSet" id="MyMenuGroup" />
	<CommandFlag>DynamicVisibility</CommandFlag>
	<Strings>
	    <ButtonText>MyMacro List</ButtonText>
	</Strings>
    </Menu>
</Menus>

위의 상태까지만 보면, "Tools" 메뉴에 "MyMacro List"라는 이름의 메뉴가 생성되도록 정의되는데 그 메뉴의 타입이 "MenuController" 유형입니다. (이 유형이어야만 자유롭게 자신의 서브 메뉴를 정의할 수 있는 것 같습니다.)

그다음, 서브 메뉴를 담을 그룹을 하나 정의합니다.

<Groups>
    <Group guid="guidMacroCommandPackageCmdSet" id="MyMenuGroup" priority="0x0600">
        <Parent guid="guidSHLMainMenu" id="IDM_VS_MENU_TOOLS"/>
    </Group>

    <Group guid="guidMacroCommandPackageCmdSet" id="MySubMenuGroup" priority="0x0600">
        <Parent guid="guidMacroCommandPackageCmdSet" id="MyMenuController" />
    </Group>
</Groups>

당연히 이 서브 메뉴 그룹의 부모는 "Tools" / "MyMacro List" 메뉴가 되어야 하므로 "<Parent />"를 통해 메뉴의 id인 "MyMenuController"를 부모로 지정해 준 것입니다. 그리고 이 서브 메뉴(예제에서는 매크로 명령어들)가 나열되기 위한 기본 버튼(이라 쓰고 메뉴라고 읽음) 하나를 지정해 줍니다.

<Buttons>
    <Button guid="guidMacroCommandPackageCmdSet" id="cmdidMyDynamicStartCommand" priority="0x0100">
    <Parent guid="guidMacroCommandPackageCmdSet" id="MySubMenuGroup" />
    <CommandFlag>DynamicItemStart</CommandFlag>
    <CommandFlag>DynamicVisibility</CommandFlag>
    <CommandFlag>TextChanges</CommandFlag>
    <Strings>
        <ButtonText>MacroSubList</ButtonText>
    </Strings>
    </Button>
</Buttons>

마지막으로, 지금까지 설정했던 노드마다 사용되었던 ID에 대한 심벌 등록을 해줍니다.

<GuidSymbol name="guidMacroCommandPackageCmdSet" value="{6d15ea04-448b-4c65-b70c-cbfaa3c105b7}">
    <IDSymbol name="MyMenuGroup" value="0x1000" />
    <IDSymbol name="MySubMenuGroup" value="0x1500" />
    <IDSymbol name="MyMenuController" value ="0x2000"/>
    <IDSymbol name="cmdidMyDynamicStartCommand" value="0x3002" />
</GuidSymbol>

일단, 여기까지의 .vsct 파일 변경과 함께 지난번의 소스 코드에서 MacroCommand의 생성자를 다음과 같이 아무 일도 안 하는 코드로 변경해서 실행하면,

private MacroCommand(Package package)
{
    if (package == null)
    {
        throw new ArgumentNullException("package");
    }
            
    this.package = package;
}

"Tools" 메뉴에 "MyMacro List" 메뉴가 포함되고, 그 하위로 "MacroSubList" 메뉴가 뜹니다.

dynamic_menu_in_vsix_1.png




우리가 원하는 것은, 바로 "MacroSubList"가 나와야 되는 단계의 메뉴를 동적으로 구성하는 것입니다. 이를 위해 MacroCommand의 생성자를 다음과 같이 변경해 줍니다.

public const int CommandId = 0x3002;  // .vsct에서 지정한 cmdidMyDynamicStartCommand 심벌 값
private DTE2 dte2;

private MacroCommand(Package package)
{
    if (package == null)
    {
        throw new ArgumentNullException("package");
    }

    _repo = new MacroRepository(); // 이 클래스 코드는 나중에 설명을 합니다.
            
    this.package = package;

    OleMenuCommandService commandService = this.ServiceProvider.GetService(typeof(IMenuCommandService)) as OleMenuCommandService;
    if (commandService != null)
    {
        CommandID dynamicItemRootId = new CommandID(CommandSet, (int)CommandId);
        DynamicItemMenuCommand dynamicMenuCommand = new DynamicItemMenuCommand(dynamicItemRootId,
            _repo,
            OnInvokedDynamicItem,
            OnBeforeQueryStatusDynamicItem);

        commandService.AddCommand(dynamicMenuCommand);
    }

    dte2 = (DTE2)this.ServiceProvider.GetService(typeof(DTE));
}

가장 다른 점이 있다면 기존의 코드는 MenuCommand 인스턴스를 생성해 commandService.AddCommand에 건네주었던 반면, 이번에는 별도로 정의한 DynamicItemMenuCommand 타입을 생성해서 전달하고 있습니다. 이 타입은 OleMenuCommand를 상속받은 것으로 다음과 같이 코드를 추가하면 됩니다.

using System;
using System.ComponentModel.Design;
using System.Globalization;
using Microsoft.VisualStudio.Shell;
using Microsoft.VisualStudio.Shell.Interop;
using EnvDTE80;

namespace VSMacro
{
    public class DynamicItemMenuCommand : OleMenuCommand
    {
        MacroRepository _repo;

        public DynamicItemMenuCommand(CommandID rootId, MacroRepository repo,
            EventHandler invokeHandler, EventHandler beforeQueryStatusHandler)
            : base(invokeHandler, null /*changeHandler*/, beforeQueryStatusHandler, rootId)
        {
            _repo = repo;
        }

        public override bool DynamicItemMatch(int cmdId)
        {
            if (_repo.ContainsCmd(cmdId))
            {
                this.MatchedCommandId = cmdId;
                return true;
            }

            return false;
        }
    }
}

DynamicItemMenuCommand가 하는 것은 오로지 Visual Studio가 조회하는 Command ID가 올바른지에 대해서만 DynamicItemMatch 가상 함수를 통해 판단해 주는 것입니다.

여기서부터, 구현이 좀 복잡하고 이해가 다소 직관적이지 않은 부분이 나옵니다. 일단, Visual Studio는 (OleMenuCommand를 상속받은) 메뉴가 있는 경우, 그 메뉴의 생성자에 전달된 마지막 인자(OnBeforeQueryStatusDynamicItem)였던 delegate를 호출합니다. 즉, 다음의 코드가 실행됩니다.

private void OnBeforeQueryStatusDynamicItem(object sender, EventArgs args)
{
    DynamicItemMenuCommand matchedCommand = (DynamicItemMenuCommand)sender;

    matchedCommand.Enabled = true;
    matchedCommand.Visible = true;

    // matchedCommand.MatchedCommandId
}

그런데, 이때 실행되는 matchedCommand의 MatchedCommandId가 수상합니다. 예상으로는 생성자 호출 시에 넘겨줬던 CommandID를 따르는 CommandId(0x3002)여야 할 텐데, Visual Studio는 ("MacroSubList"로 이미 하나 점유하고 있던) 첫 번째 메뉴에 대해 matchedCommand.MatchedCommandId == 0으로 설정해서 호출합니다. 따라서, 동적 메뉴의 첫 번째 항목을 구분하는 Command ID는 0이 되고, 그에 따라 텍스트를 다음과 같은 식으로 변경해 줘야 합니다.

private void OnBeforeQueryStatusDynamicItem(object sender, EventArgs args)
{
    DynamicItemMenuCommand matchedCommand = (DynamicItemMenuCommand)sender;

    matchedCommand.Enabled = true;
    matchedCommand.Visible = true;

    if (matchedCommand.MatchedCommandId == 0)
    {
        matchedCommand.Text = "CMD1";
    }
}

즉, 동적 메뉴라고는 하지만 최소 1개는 이미 포함하고 있는 상태의 메뉴를 구성해야 하는 것입니다.

그다음, Visual Studio는 추가로 메뉴가 있는지에 대해 알아내야 하는데 이를 위해 (OleMenuCommand를 상속받은) DynamicItemMenuCommand로 cmdidMyDynamicStartCommand(0x3002) 값에 +1을 하면서 그 값이 유효한 지에 대해 OleMenuCommand.DynamicItemMatch 메서드를 통해 묻습니다. 따라서 표시해야 할 동적 메뉴가 2개 더 있다면 다음과 같이 구현해 줘야 합니다.

public override bool DynamicItemMatch(int cmdId)
{
    if (cmdId == 0)
    {
        return true; // 가장 첫 번째 메뉴 == 유효
    }

    if (cmdId == (MacroCommand.CommandId + 1))
    {
        this.MatchedCommandId = cmdId;
        return true;  // 두 번째 메뉴 == 유효
    }

    if (cmdId == (MacroCommand.CommandId + 2))
    {
        this.MatchedCommandId = cmdId;
        return true; // 세 번째 메뉴 == 유효
    }

    return false; // 이외의 메뉴 ID들은 유효하지 않음.
}

이쯤에서, 위와 같이 구성하는 것이 다소 번거로우니 동적 메뉴를 위한 구성을 담을 MacroRepository 코드를 다음과 같이 작성합니다.

using System.Collections.Generic;

namespace VSMacro
{
    public class MacroRepository
    {
        List<MacroItem> _macros = new List<MacroItem>();

        public MacroRepository()
        {
            _macros.Add(new MacroItem(0, "CMD1", "__cmd1__"));
            _macros.Add(new MacroItem(MacroCommand.CommandId + 1, "CMD2", "__cmd2__"));
            _macros.Add(new MacroItem(MacroCommand.CommandId + 2, "CMD3", "__cmd3__"));
        }

        public string GetCommandTextByPos(int index)
        {
            if (index != 0)
            {
                index = index - MacroCommand.CommandId;
            }

            return _macros[index].Text;
        }

        public string GetCommandSourceCodeByPos(int index)
        {
            if (index != 0)
            {
                index = index - MacroCommand.CommandId;
            }

            return _macros[index].SourceCode;
        }

        public bool ContainsCmd(int cmdId)
        {
            return _macros.Exists((item) => item.Id == cmdId);
        }
    }

    public class MacroItem
    {
        int _id;
        public int Id
        {
            get { return _id; }
        }

        string _text;
        public string Text
        {
            get { return _text; }
        }

        string _sourceCode;
        public string SourceCode
        {
            get { return _sourceCode; }
        }

        public MacroItem()
        {
        }

        public MacroItem(int id, string text, string sourceCode)
        {
            _id = id;
            _text = text;
            _sourceCode = sourceCode;
        }
    }
}

그럼, DynamicItemMenuCommand.DynamicItemMatch 코드를 다음과 같이 변경할 수 있고,

public override bool DynamicItemMatch(int cmdId)
{
    if (_repo.ContainsCmd(cmdId))
    {
        this.MatchedCommandId = cmdId; // 이때 설정한 MatchedCommandId 값 덕분에,
                                        // 연이어 호출되는 OnBeforeQueryStatusDynamicItem 메서드에서
                                        // 방금 전에 Visual Studio가 조회한 동적 메뉴의 ID가 무엇인지 알 수 있게 됨.
        return true;
    }

    return false;
}

MacroCommand.OnBeforeQueryStatusDynamicItem 메서드는 이렇게 정의할 수 있습니다.

private void OnBeforeQueryStatusDynamicItem(object sender, EventArgs args)
{
    DynamicItemMenuCommand matchedCommand = (DynamicItemMenuCommand)sender;

    matchedCommand.Enabled = true;
    matchedCommand.Visible = true;

    matchedCommand.Text = _repo.GetCommandTextByPos(matchedCommand.MatchedCommandId);

    matchedCommand.MatchedCommandId = 0;
}

위의 코드들을 보시면, 좀 이해가 안 되는 부분이 있습니다. 전통적인 생각으로는, 동적 메뉴를 구성한다면 Menu 객체가 하나 있고 그것의 Children 속성에 개별 메뉴에 해당하는 Menu 인스턴스들이 생성될 것으로 예상하게 됩니다. 가령 다음과 같은 코드를 상상할 수 있습니다.

// Visual Studio 측 (가상 코드)
Menu root = new Menu("MyMacro List");
MenuExtension_AddSubMenu(root);

// 확장 모듈 측
MenuExtension_AddSubMenu(root)
{
    root.Children.Add(new Menu("CMD1"));
    root.Children.Add(new Menu("CMD2"));
    root.Children.Add(new Menu("CMD3"));
}

그런데, Visual Studio는 다음과 같은 식으로 동적 메뉴를 구성하고 있습니다.

// Visual Studio 측 (가상 코드)
Menu root = new Menu("MyMacro List");
Menu subMenu = new Menu("MacroSubList");

OleMenuCommand dynamicMenu = new ExtensionMenu(subMenu);

dynamicMenu.MatchedCommandId = 0;
dynamicMenu.OnBeforeQueryStatusDynamicItem(dynamicMenu, EventArgs.Empty); // 이때 subMenu.Text 속성에 "CMD1"을 설정해야 함.

int startId = dynamicMenu.MatchedCommandId + 1;
while (true)
{
    dynamicMenu.MatchedCommandId = startId;

    if (dynamicMenu.DynamicItemMatch(startId) == false)
    {
        break;
    }

    dynamicMenu.OnBeforeQueryStatusDynamicItem(dynamicMenu, EventArgs.Empty); // 이때 2번째 이후의 동적 메뉴의 문자열을 설정
    startId ++;
}

가만 보면, OleMenuCommand 인스턴스 하나로 동적으로 추가해야 할 모든 메뉴를 처리하고 있는 것입니다. 즉, OnBeforeQueryStatusDynamicItem이나 DynamicItemMatch가 호출될 때의 메뉴 인스턴스는 모두 동일하게/단일한 DynamicItemMenuCommand 인스턴스가 되는 것입니다. (그렇습니다, 처리 절차가 일반적이지 않아서 좀 혼란스럽습니다.)

암튼 저렇게 해서 결국 동적 메뉴로 구성된 항목을 사용자가 선택하면 DynamicItemMenuCommand의 3번째 인자로 넘겨준 OnInvokedDynamicItem 메서드를 호출해 줍니다.

private void OnInvokedDynamicItem(object sender, EventArgs args)  
{  
    DynamicItemMenuCommand invokedCommand = (DynamicItemMenuCommand)sender;  

    // invokedCommand.MatchedCommandId
    // invokedCommand.Text
}  

재미있는 것은, 이 메서드가 호출될 때의 invokedCommand.MatchedCommandId는 해당 메뉴의 존재를 확인하기 위해 DynamicItemMatch를 통해 주고받았던 ID라고 장담할 수 없습니다. 왜냐하면 결국 동적 메뉴를 의미하는 DynamicItemMenuCommand 객체는 1개이기 때문에 그 인스턴스에 현재 어떤 값을 설정하고 있느냐에 따라 달라질 수 있기 때문입니다. 즉, 개발자가 작성한 코드가 어떤 식이냐에 따라 MatchedCommandId가 선택된 메뉴의 ID가 맞을 수도/맞지 않을 수도 있습니다.

사실 Visual Studio는 선택된 동적 메뉴의 MatchedCommandId를 보관하고 있지 않는 것처럼(실제로 보관하고 있지 않는 듯) 행동합니다. 그래서, 첫 번째 메뉴가 선택되었을 때 Visual Studio는 OnInvokedDynamicItem을 다음과 같은 문맥으로 호출해 줍니다.

// Visual Studio 측 (가상 코드)

OleMenuCommand dynamicMenu = ...;

dynamicMenu.MatchedCommandId = 0;
dynamicMenu.OnBeforeQueryStatusDynamicItem(dynamicMenu, EventArgs.Empty);
dynamicMenu.OnInvokedDynamicItem(dynamicMenu, EventArgs.Empty);

즉, 첫 번째 메뉴이기 때문에 MatchedCommandId를 0으로 설정하고 OnBeforeQueryStatusDynamicItem 호출로 상태를 확인한 다음 OnInvokedDynamicItem을 호출하는 것입니다.

동적으로 추가된 두 번째 메뉴부터는 말 그대로 .vsct에 등록했던 cmdidMyDynamicStartCommand(0x3002)의 값에 "+ N"을 하면서 이렇게 호출합니다.

// Visual Studio 측 (가상 코드)
OleMenuCommand dynamicMenu = ...;

// 2번째 메뉴가 선택되었다면,
dynamicMenu.DynamicItemMatch(cmdidMyDynamicStartCommand + 1);           // 호출로 인해 dynamicMenu.MatchedCommandId == cmdidMyDynamicStartCommand + 1로 설정됨
dynamicMenu.OnBeforeQueryStatusDynamicItem(dynamicMenu, EventArgs.Empty); // 호출로 인해 dynamicMenu.MatchedCommandId == 0으로 설정됨
dynamicMenu.DynamicItemMatch(cmdidMyDynamicStartCommand + 1);           // 호출로 인해 다시 dynamicMenu.MatchedCommandId == cmdidMyDynamicStartCommand + 1로 설정됨
dynamicMenu.OnInvokedDynamicItem(dynamicMenu, EventArgs.Empty);         // 결국 dynamicMenu.MatchedCommandId == cmdidMyDynamicStartCommand + 1 값으로 호출됨

// 3번째 메뉴가 선택되었다면,
dynamicMenu.DynamicItemMatch(cmdidMyDynamicStartCommand + 2);           // 호출로 인해 dynamicMenu.MatchedCommandId == cmdidMyDynamicStartCommand + 2로 설정됨
dynamicMenu.OnBeforeQueryStatusDynamicItem(dynamicMenu, EventArgs.Empty); // 호출로 인해 dynamicMenu.MatchedCommandId == 0으로 설정됨
dynamicMenu.DynamicItemMatch(cmdidMyDynamicStartCommand + 2);           // 호출로 인해 다시 dynamicMenu.MatchedCommandId == cmdidMyDynamicStartCommand + 1로 설정됨
dynamicMenu.OnInvokedDynamicItem(dynamicMenu, EventArgs.Empty);         // 결국 dynamicMenu.MatchedCommandId == cmdidMyDynamicStartCommand + 1 값으로 호출됨

저 상황을 보고, DynamicItemMatch, OnBeforeQueryStatusDynamicItem의 메서드 구현을 다시 보면 왜 MatchedCommandId의 값에 0이나 cmdId를 명시적으로 설정해 주는 코드가 들어가야 하는지 이해할 수 있습니다.




위와 같은 코드로 Visual Studio를 실행하면 다음과 같은 화면을 볼 수 있고,

dynamic_menu_in_vsix_2.png

각각의 메뉴를 선택하면, MacroItem 객체에 설정했던 SourceCode 속성의 문자열이 메시지 상자로 뜨는 것을 확인할 수 있습니다.

(이 글의 첨부 파일은 예제 코드를 포함합니다.)




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]






[최초 등록일: ]
[최종 수정일: 7/13/2021]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 작성자
 




1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...
NoWriterDateCnt.TitleFile(s)
13256정성태2/10/20235188개발 환경 구성: 665. WSL 2의 네트워크 통신 방법 - 두 번째 이야기
13255정성태2/10/20234522오류 유형: 845. gihub - windows2022 이미지에서 .NET Framework 4.5.2 미만의 프로젝트에 대한 빌드 오류
13254정성태2/10/20234408Windows: 223. (WMI 쿼리를 위한) PowerShell 문자열 escape 처리
13253정성태2/9/20235183Windows: 222. C# - 다른 윈도우 프로그램이 실행되었음을 인식하는 방법파일 다운로드1
13252정성태2/9/20234006오류 유형: 844. ssh로 명령어 수행 시 멈춤 현상
13251정성태2/8/20234459스크립트: 44. 파이썬의 3가지 스레드 ID
13250정성태2/8/20236278오류 유형: 843. System.InvalidOperationException - Unable to configure HTTPS endpoint
13249정성태2/7/20235124오류 유형: 842. 리눅스 - You must wait longer to change your password
13248정성태2/7/20234171오류 유형: 841. 리눅스 - [사용자 계정] is not in the sudoers file. This incident will be reported.
13247정성태2/7/20235072VS.NET IDE: 180. Visual Studio - 닷넷 소스 코드 디버깅 중 "Decompile source code"가 동작하는 않는 문제
13246정성태2/6/20234201개발 환경 구성: 664. Hyper-V에 설치한 리눅스 VM의 VHD 크기 늘리는 방법 - 두 번째 이야기
13245정성태2/6/20234773.NET Framework: 2093. C# - PEM 파일을 이용한 RSA 개인키/공개키 설정 방법파일 다운로드1
13244정성태2/5/20234128VS.NET IDE: 179. Visual Studio - External Tools에 Shell 내장 명령어 등록
13243정성태2/5/20234979디버깅 기술: 190. windbg - Win32 API 호출 시점에 BP 거는 방법 [1]
13242정성태2/4/20234421디버깅 기술: 189. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.UnauthorizedAccessException
13241정성태2/3/20233921디버깅 기술: 188. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.IO.FileNotFoundException
13240정성태2/1/20234077디버깅 기술: 187. ASP.NET Web Application (.NET Framework) 프로젝트의 숨겨진 예외 - System.Web.HttpException
13239정성태2/1/20233739디버깅 기술: 186. C# - CacheDependency의 숨겨진 예외 - System.Web.HttpException
13238정성태1/31/20235822.NET Framework: 2092. IIS 웹 사이트를 TLS 1.2 또는 TLS 1.3 프로토콜로만 운영하는 방법
13237정성태1/30/20235483.NET Framework: 2091. C# - 웹 사이트가 어떤 버전의 TLS/SSL을 지원하는지 확인하는 방법
13236정성태1/29/20235091개발 환경 구성: 663. openssl을 이용해 인트라넷 IIS 사이트의 SSL 인증서 생성
13235정성태1/29/20234649개발 환경 구성: 662. openssl - 윈도우 환경의 명령행에서 SAN 적용하는 방법
13234정성태1/28/20235730개발 환경 구성: 661. dnSpy를 이용해 소스 코드가 없는 .NET 어셈블리의 코드를 변경하는 방법 [1]
13233정성태1/28/20237060오류 유형: 840. C# - WebClient로 https 호출 시 "The request was aborted: Could not create SSL/TLS secure channel" 예외 발생
13232정성태1/27/20234823스크립트: 43. uwsgi의 --processes와 --threads 옵션
13231정성태1/27/20233740오류 유형: 839. python - TypeError: '...' object is not callable
1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...